feat: message logger history viewer

This commit is contained in:
rhunk
2024-02-11 23:10:10 +01:00
parent 53204a2b42
commit fe777ff1d8
10 changed files with 442 additions and 34 deletions

View File

@@ -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)

View File

@@ -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
)
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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"
},

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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))
}

View File

@@ -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))
}
}

View File

@@ -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()

View File

@@ -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()
}