commit fe777ff1d8a1e0fcb9662c14d988ed321627382e
parent 53204a2b4275a56d2d38e25a80bebffdd9bde65f
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sun, 11 Feb 2024 23:10:10 +0100

feat: message logger history viewer

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt | 2++
Aapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt | 355+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt | 8++++++++
Mcommon/src/main/assets/lang/en_US.json | 3+++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt | 1+
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt | 8++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt | 4----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt | 3++-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt | 3++-
10 files changed, 444 insertions(+), 34 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/Routes.kt @@ -15,6 +15,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraphBuilder import me.rhunk.snapenhance.RemoteSideContext +import me.rhunk.snapenhance.ui.manager.pages.LoggerHistoryRoot import me.rhunk.snapenhance.ui.manager.pages.TasksRoot import me.rhunk.snapenhance.ui.manager.pages.features.FeaturesRoot import me.rhunk.snapenhance.ui.manager.pages.home.HomeLogs @@ -51,6 +52,7 @@ class Routes( val home = route(RouteInfo("home", icon = Icons.Default.Home, primary = true), HomeRoot()) val settings = route(RouteInfo("home_settings"), HomeSettings()).parent(home) val homeLogs = route(RouteInfo("home_logs"), HomeLogs()).parent(home) + val loggerHistory = route(RouteInfo("logger_history"), LoggerHistoryRoot()).parent(home) val social = route(RouteInfo("social", icon = Icons.Default.Group, primary = true), SocialRoot()) val manageScope = route(RouteInfo("manage_scope/?scope={scope}&id={id}"), ManageScope()).parent(social) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt @@ -0,0 +1,354 @@ +package me.rhunk.snapenhance.ui.manager.pages + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavBackStackEntry +import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.bridge.wrapper.LoggedMessage +import me.rhunk.snapenhance.common.bridge.wrapper.MessageLoggerWrapper +import me.rhunk.snapenhance.common.data.ContentType +import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.DecodedAttachment +import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder +import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.ui.manager.Routes +import kotlin.math.absoluteValue + + +class LoggerHistoryRoot : Routes.Route() { + private lateinit var messageLoggerWrapper: MessageLoggerWrapper + private var selectedConversation by mutableStateOf<String?>(null) + private var stringFilter by mutableStateOf("") + private var reverseOrder by mutableStateOf(true) + + private inline fun decodeMessage(message: LoggedMessage, result: (contentType: ContentType, messageReader: ProtoReader, attachments: List<DecodedAttachment>) -> Unit) { + runCatching { + val messageObject = JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject + val messageContent = messageObject.getAsJsonObject("mMessageContent") + val messageReader = messageContent.getAsJsonArray("mContent").map { it.asByte }.toByteArray().let { ProtoReader(it) } + result(ContentType.fromMessageContainer(messageReader) ?: ContentType.UNKNOWN, messageReader, MessageDecoder.decode(messageContent)) + }.onFailure { + context.log.error("Failed to decode message", it) + } + } + + private fun downloadAttachment(creationTimestamp: Long, attachment: DecodedAttachment) { + context.shortToast("Download started!") + val attachmentHash = attachment.mediaUniqueId!!.longHashCode().absoluteValue.toString() + + DownloadProcessor( + remoteSideContext = context, + callback = object: DownloadCallback.Default() { + override fun onSuccess(outputPath: String?) { + context.shortToast("Downloaded to $outputPath") + } + + override fun onFailure(message: String?, throwable: String?) { + context.shortToast("Failed to download $message") + } + } + ).enqueue( + DownloadRequest( + inputMedias = arrayOf( + InputMedia( + content = attachment.mediaUrlKey!!, + type = DownloadMediaType.PROTO_MEDIA, + encryption = attachment.attachmentInfo?.encryption, + ) + ) + ), + DownloadMetadata( + mediaIdentifier = attachmentHash, + outputPath = createNewFilePath( + context.config.root, + attachment.mediaUniqueId!!, + MediaDownloadSource.MESSAGE_LOGGER, + attachmentHash, + creationTimestamp + ), + iconUrl = null, + mediaAuthor = null, + downloadSource = MediaDownloadSource.MESSAGE_LOGGER.translate(context.translation), + ) + ) + } + + @OptIn(ExperimentalLayoutApi::class) + @Composable + private fun MessageView(message: LoggedMessage) { + var contentView by remember { mutableStateOf<@Composable () -> Unit>({ + Spacer(modifier = Modifier.height(30.dp)) + }) } + + OutlinedCard( + modifier = Modifier + .padding(2.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(4.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + contentView() + + LaunchedEffect(Unit, message) { + runCatching { + decodeMessage(message) { contentType, messageReader, attachments -> + if (contentType == ContentType.CHAT) { + val content = messageReader.getString(2, 1) ?: "[empty chat message]" + contentView = { + Text(content, modifier = Modifier + .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures(onLongPress = { + context.androidContext.copyToClipboard(content) + }) + }) + } + return@runCatching + } + contentView = { + Column column@{ + Text("[$contentType]") + if (attachments.isEmpty()) return@column + + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + attachments.forEachIndexed { index, attachment -> + ElevatedButton(onClick = { + context.coroutineScope.launch { + runCatching { + downloadAttachment(message.timestamp, attachment) + }.onFailure { + context.log.error("Failed to download attachment", it) + context.shortToast("Failed to download attachment") + } + } + }) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = "Download", + modifier = Modifier.padding(end = 4.dp) + ) + Text("Attachment ${index + 1}") + } + } + } + } + } + } + }.onFailure { + context.log.error("Failed to parse message", it) + contentView = { + Text("[Failed to parse message]") + } + } + } + } + } + } + + + @OptIn(ExperimentalMaterial3Api::class) + override val content: @Composable (NavBackStackEntry) -> Unit = { + LaunchedEffect(Unit) { + messageLoggerWrapper = MessageLoggerWrapper( + context.androidContext.getDatabasePath("message_logger.db") + ) + } + + Column { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedConversation ?: "Select a conversation", + onValueChange = {}, + readOnly = true, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + + val conversations = remember { mutableStateListOf<String>() } + + LaunchedEffect(Unit) { + conversations.clear() + withContext(Dispatchers.IO) { + conversations.addAll(messageLoggerWrapper.getAllConversations()) + } + } + + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + conversations.forEach { conversation -> + DropdownMenuItem(onClick = { + selectedConversation = conversation + expanded = false + }, text = { + Text(conversation) + }) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + horizontalArrangement = Arrangement.End + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text("Reverse order") + Checkbox(checked = reverseOrder, onCheckedChange = { + reverseOrder = it + }) + } + } + + var hasReachedEnd by remember(selectedConversation, stringFilter, reverseOrder) { mutableStateOf(false) } + var lastFetchMessageTimestamp by remember(selectedConversation, stringFilter, reverseOrder) { mutableLongStateOf(if (reverseOrder) Long.MAX_VALUE else Long.MIN_VALUE) } + val messages = remember(selectedConversation, stringFilter, reverseOrder) { mutableStateListOf<LoggedMessage>() } + + LazyColumn { + items(messages) { message -> + MessageView(message) + } + item { + if (selectedConversation != null) { + if (hasReachedEnd) { + Text("No more messages", modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), textAlign = TextAlign.Center) + } else { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator( + modifier = Modifier + .height(20.dp) + .padding(8.dp) + ) + } + } + } + LaunchedEffect(Unit, selectedConversation, stringFilter, reverseOrder) { + withContext(Dispatchers.IO) { + val newMessages = messageLoggerWrapper.fetchMessages( + selectedConversation ?: return@withContext, + lastFetchMessageTimestamp, + 30, + reverseOrder + ) { messageData -> + if (stringFilter.isEmpty()) return@fetchMessages true + var isMatch = false + decodeMessage(messageData) { contentType, messageReader, _ -> + if (contentType == ContentType.CHAT) { + val content = messageReader.getString(2, 1) ?: return@decodeMessage + isMatch = content.contains(stringFilter, ignoreCase = true) + } + } + isMatch + } + if (newMessages.isEmpty()) { + hasReachedEnd = true + return@withContext + } + lastFetchMessageTimestamp = newMessages.lastOrNull()?.timestamp ?: return@withContext + withContext(Dispatchers.Main) { + messages.addAll(newMessages) + } + } + } + } + } + } + } + + override val topBarActions: @Composable (RowScope.() -> Unit) = { + val focusRequester = remember { FocusRequester() } + var showSearchTextField by remember { mutableStateOf(false) } + + if (showSearchTextField) { + var searchValue by remember { mutableStateOf("") } + + TextField( + value = searchValue, + onValueChange = { keyword -> + searchValue = keyword + }, + keyboardActions = KeyboardActions(onDone = { focusRequester.freeFocus() }), + modifier = Modifier + .focusRequester(focusRequester) + .weight(1f, fill = true) + .padding(end = 10.dp) + .height(70.dp), + singleLine = true, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary + ) + ) + ElevatedButton(onClick = { + stringFilter = searchValue + }) { + Text("Search") + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } + + IconButton(onClick = { + showSearchTextField = !showSearchTextField + stringFilter = "" + }) { + Icon( + imageVector = if (showSearchTextField) Icons.Filled.Close + else Icons.Filled.Search, + contentDescription = null + ) + } + } +}+ \ No newline at end of file diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/home/HomeSettings.kt @@ -183,6 +183,14 @@ class HomeSettings : Routes.Route() { Text(text = "Clear") } } + OutlinedButton( + modifier = Modifier.fillMaxWidth().padding(5.dp), + onClick = { + routes.loggerHistory.navigate() + } + ) { + Text(text = "View Message History") + } } } diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -28,6 +28,8 @@ "home": "Home", "home_settings": "Settings", "home_logs": "Logs", + "logger_history": "Logger History", + "logged_stories": "Logged Stories", "social": "Social", "manage_scope": "Manage Scope", "messaging_preview": "Preview", @@ -940,6 +942,7 @@ "spotlight": "Spotlight", "profile_picture": "Profile Picture", "story_logger": "Story Logger", + "message_logger": "Message Logger", "merged": "Merged" }, diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/bridge/wrapper/MessageLoggerWrapper.kt @@ -12,6 +12,13 @@ import me.rhunk.snapenhance.common.util.ktx.getStringOrNull import java.io.File import java.util.UUID + +class LoggedMessage( + val messageId: Long, + val timestamp: Long, + val messageData: ByteArray +) + class MessageLoggerWrapper( val databaseFile: File ): MessageLoggerInterface.Stub() { @@ -60,29 +67,24 @@ class MessageLoggerWrapper( 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)) + return database.rawQuery("SELECT message_id FROM messages WHERE conversation_id IN (${ + conversationId.joinToString(",") { "'$it'" } + }) ORDER BY message_id DESC LIMIT $limit", null).use { + val ids = mutableListOf<Long>() + while (it.moveToNext()) { + ids.add(it.getLong(0)) + } + ids.toLongArray() } - cursor.close() - return ids.toLongArray() } 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 + return database.rawQuery( + "SELECT message_data FROM messages WHERE conversation_id = ? AND message_id = ?", + arrayOf(conversationId, id.toString()) + ).use { + if (it.moveToFirst()) it.getBlob(0) else null } - cursor.close() - return message } override fun addMessage(conversationId: String, messageId: Long, serializedMessage: ByteArray) { @@ -116,19 +118,17 @@ class MessageLoggerWrapper( } fun getStoredMessageCount(): Int { - val cursor = database.rawQuery("SELECT COUNT(*) FROM messages", null) - cursor.moveToFirst() - val count = cursor.getInt(0) - cursor.close() - return count + return database.rawQuery("SELECT COUNT(*) FROM messages", null).use { + it.moveToFirst() + it.getInt(0) + } } fun getStoredStoriesCount(): Int { - val cursor = database.rawQuery("SELECT COUNT(*) FROM stories", null) - cursor.moveToFirst() - val count = cursor.getInt(0) - cursor.close() - return count + return database.rawQuery("SELECT COUNT(*) FROM stories", null).use { + it.moveToFirst() + it.getInt(0) + } } override fun deleteMessage(conversationId: String, messageId: Long) { @@ -174,4 +174,39 @@ class MessageLoggerWrapper( } return stories } + + fun getAllConversations(): List<String> { + return database.rawQuery("SELECT DISTINCT conversation_id FROM messages", null).use { + val conversations = mutableListOf<String>() + while (it.moveToNext()) { + conversations.add(it.getString(0)) + } + conversations + } + } + + fun fetchMessages( + conversationId: String, + fromTimestamp: Long, + limit: Int, + reverseOrder: Boolean = true, + filter: ((LoggedMessage) -> Boolean)? = null + ): List<LoggedMessage> { + val messages = mutableListOf<LoggedMessage>() + database.rawQuery( + "SELECT * FROM messages WHERE conversation_id = ? AND added_timestamp ${if (reverseOrder) "<" else ">"} ? ORDER BY added_timestamp ${if (reverseOrder) "DESC" else "ASC"}", + arrayOf(conversationId, fromTimestamp.toString()) + ).use { + while (it.moveToNext() && messages.size < limit) { + val message = LoggedMessage( + messageId = it.getLongOrNull("message_id") ?: continue, + timestamp = it.getLongOrNull("added_timestamp") ?: continue, + messageData = it.getBlobOrNull("message_data") ?: continue + ) + if (filter != null && !filter(message)) continue + messages.add(message) + } + } + return messages + } } \ No newline at end of file diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt @@ -15,6 +15,7 @@ enum class MediaDownloadSource( SPOTLIGHT("spotlight", "spotlight"), PROFILE_PICTURE("profile_picture", "profile_picture"), STORY_LOGGER("story_logger", "story_logger"), + MESSAGE_LOGGER("message_logger", "message_logger"), MERGED("merged", "merged"); fun matches(source: String?): Boolean { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/util/ktx/AndroidCompatExtensions.kt @@ -1,5 +1,7 @@ package me.rhunk.snapenhance.common.util.ktx +import android.content.ClipData +import android.content.Context import android.content.pm.PackageManager import android.content.pm.PackageManager.ApplicationInfoFlags import android.os.Build @@ -11,3 +13,8 @@ fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int) = @Suppress("DEPRECATION") getApplicationInfo(packageName, flags) } + +fun Context.copyToClipboard(data: String, label: String = "Copied Text") { + getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip( + ClipData.newPlainText(label, data)) +}+ \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ModContext.kt @@ -158,8 +158,4 @@ class ModContext( fun getConfigLocale(): String { return _config.locale } - - fun copyToClipboard(data: String, label: String = "Copied Text") { - androidContext.getSystemService(android.content.ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText(label, data)) - } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -19,6 +19,7 @@ import me.rhunk.snapenhance.common.data.MessagingRuleType import me.rhunk.snapenhance.common.data.download.* import me.rhunk.snapenhance.common.database.impl.ConversationMessage import me.rhunk.snapenhance.common.database.impl.FriendInfo +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.snap.BitmojiSelfie @@ -161,7 +162,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp setTitle("Debug Media Info") setView(debugEditText(context, mediaInfoText)) setNeutralButton("Copy") { _, _ -> - this@MediaDownloader.context.copyToClipboard(mediaInfoText) + context.copyToClipboard(mediaInfoText) } setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() } }.show() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/ChatActionMenu.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.ui.createComposeView +import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.protobuf.ProtoReader import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver import me.rhunk.snapenhance.core.features.impl.downloader.MediaDownloader @@ -74,7 +75,7 @@ class ChatActionMenu : AbstractMenu() { setView(debugEditText(context, text)) setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } setNegativeButton("Copy") { _, _ -> - this@ChatActionMenu.context.copyToClipboard(text, title) + context.copyToClipboard(text, title) } }.show() }