feat: add message logger export individual chat

This commit is contained in:
schrodingerspet
2026-04-19 01:01:38 +05:30
parent ca37953476
commit 22bbadf926
6 changed files with 1680 additions and 60 deletions

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.SharedPreferences
import android.content.Intent
import com.google.gson.JsonParser
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -29,18 +30,31 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerConversationExportTarget
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
import me.eternal.purrfectsnap.storage.findFriend
import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.storage.getGroupInfo
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
@@ -60,6 +74,8 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import java.io.File
import java.net.URLEncoder
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -257,11 +273,484 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
var showExportOptionsDialog by remember { mutableStateOf(false) }
var showConversationExportDialog by remember { mutableStateOf(false) }
var showConversationFormatDialog by remember { mutableStateOf(false) }
var conversationSearchQuery by remember { mutableStateOf("") }
var selectedConversationForExport by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
var pendingConversationExportTarget by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
val loggerHistoryTranslation = remember { context.translation.getCategory("logger_history") }
data class ConversationSearchTarget(
val target: LoggerConversationExportTarget,
val friendDisplayName: String?,
val friendUsername: String?,
val chatDisplayName: String?,
val groupDisplayName: String?,
val readableUsernames: List<String>,
val readableIdentifiers: List<String>,
val isDirectChat: Boolean,
val isGroupChat: Boolean,
val sortOrder: Int
)
data class ConversationExportFormat(
val extension: String,
val mimeType: String,
val label: String
)
data class ParsedConversationMessage(
val senderId: String,
val senderUsername: String,
val timestamp: Long,
val contentType: ContentType,
val messageText: String?,
val attachments: List<DecodedAttachment>
)
fun String.isUuidLike(): Boolean {
val value = trim()
if (value.length != 36) return false
if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') return false
return value.filterIndexed { index, _ ->
index != 8 && index != 13 && index != 18 && index != 23
}.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
}
fun String.isLikelyInternalId(): Boolean {
val value = trim()
if (value.isUuidLike()) return true
if (value.length >= 10 && value.all(Char::isDigit)) return true
if (value.length >= 16 && value.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' || it == '-' }) {
val digitCount = value.count(Char::isDigit)
val alphaCount = value.count { it.lowercaseChar() in 'a'..'f' }
if (digitCount >= 4 && alphaCount >= 4) return true
}
return false
}
fun String.toReadableIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.isLikelyInternalId()) return null
if (!value.any { it.isLetter() }) return null
return value
}
fun String.toSearchIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.equals("myai", ignoreCase = true)) return null
return value
}
val exportTargets by rememberAsyncMutableState(defaultValue = emptyList<LoggerConversationExportTarget>()) {
context.messageLogger.getConversationExportTargets()
}
val exportSearchTargets by rememberAsyncMutableState(
defaultValue = emptyList<ConversationSearchTarget>(),
keys = arrayOf(exportTargets)
) {
val friendIdentityCache = mutableMapOf<String, Pair<String?, String?>?>()
exportTargets.mapIndexedNotNull { index, target ->
val friend = context.database.findFriend(target.conversationId)
val group = context.database.getGroupInfo(target.conversationId)
val chatDisplayName = target.groupTitle
?.toReadableIdentityOrNull()
?.takeIf { !it.equals(target.conversationId, ignoreCase = true) }
val friendDisplayName = friend?.displayName?.toReadableIdentityOrNull()
val friendUsername = friend?.mutableUsername?.toReadableIdentityOrNull()
val searchableUsernames = target.usernames
.mapNotNull { it.toSearchIdentityOrNull() }
.distinct()
val readableUsernames = searchableUsernames
.mapNotNull { it.toReadableIdentityOrNull() }
.distinct()
val hasManyParticipants = target.userIds.distinct().size > 2 || searchableUsernames.size > 2
val fallbackFriendIdentities = if (friend == null && !hasManyParticipants) {
target.userIds.mapNotNull { userId ->
friendIdentityCache.getOrPut(userId) {
context.database.getFriendInfo(userId)?.let {
it.displayName?.toReadableIdentityOrNull() to
it.mutableUsername.toReadableIdentityOrNull()
}
}?.takeIf { it.first != null || it.second != null }
}
} else {
emptyList()
}
val fallbackFriendDisplayName = fallbackFriendIdentities.firstNotNullOfOrNull { it.first }
val fallbackFriendUsername = fallbackFriendIdentities.firstNotNullOfOrNull { it.second }
val resolvedFriendDisplayName = friendDisplayName ?: fallbackFriendDisplayName
val resolvedFriendUsername = friendUsername ?: fallbackFriendUsername
val groupDisplayName = group?.name?.toReadableIdentityOrNull()
?: chatDisplayName?.takeIf { hasManyParticipants }
val isGroupChat = groupDisplayName != null || hasManyParticipants
val isDirectChat = !isGroupChat
val readableIdentifiers = buildList {
add(target.conversationId)
addAll(target.userIds)
resolvedFriendDisplayName?.let { add(it) }
resolvedFriendUsername?.let { add(it) }
chatDisplayName?.let { add(it) }
groupDisplayName?.let { add(it) }
addAll(searchableUsernames)
addAll(readableUsernames)
}.distinct()
ConversationSearchTarget(
target = target,
friendDisplayName = resolvedFriendDisplayName,
friendUsername = resolvedFriendUsername,
chatDisplayName = chatDisplayName,
groupDisplayName = groupDisplayName,
readableUsernames = readableUsernames,
readableIdentifiers = readableIdentifiers,
isDirectChat = isDirectChat,
isGroupChat = isGroupChat,
sortOrder = index
)
}.sortedWith(
compareBy<ConversationSearchTarget> {
when {
it.isDirectChat -> 0
it.isGroupChat -> 1
else -> 2
}
}.thenBy { it.sortOrder }
)
}
val filteredExportTargets = remember(exportSearchTargets, conversationSearchQuery) {
val query = conversationSearchQuery.trim()
if (query.isBlank()) {
exportSearchTargets
} else {
exportSearchTargets.filter { searchTarget ->
searchTarget.readableIdentifiers.any {
it.contains(query, ignoreCase = true)
}
}
}
}
val exportFormats = remember {
listOf(
ConversationExportFormat("db", "application/octet-stream", ".db"),
ConversationExportFormat("html", "text/html", "HTML"),
ConversationExportFormat("txt", "text/plain", "TXT")
)
}
fun formatExportTarget(searchTarget: ConversationSearchTarget): String {
searchTarget.friendDisplayName?.let { displayName ->
val username = searchTarget.friendUsername
val formattedName = if (username != null && !username.equals(displayName, ignoreCase = true)) {
"$displayName • @$username"
} else {
displayName
}
return loggerHistoryTranslation.format("list_friend_format", "name" to formattedName)
}
searchTarget.friendUsername?.let { username ->
return loggerHistoryTranslation.format("list_friend_format", "name" to "@$username")
}
searchTarget.chatDisplayName?.takeIf { searchTarget.isDirectChat }?.let {
return loggerHistoryTranslation.format("list_friend_format", "name" to it)
}
if (searchTarget.isDirectChat && searchTarget.readableUsernames.isNotEmpty()) {
val friendName = if (searchTarget.readableUsernames.size == 1) {
searchTarget.readableUsernames.first()
} else {
searchTarget.readableUsernames.joinToString(", ")
}
return loggerHistoryTranslation.format("list_friend_format", "name" to friendName)
}
searchTarget.groupDisplayName?.let {
return loggerHistoryTranslation.format("list_group_format", "name" to it)
}
if (searchTarget.readableUsernames.isNotEmpty()) {
return loggerHistoryTranslation.format(
"list_group_format",
"name" to searchTarget.readableUsernames.joinToString(", ")
)
}
return if (searchTarget.isGroupChat) {
loggerHistoryTranslation.format("list_group_format", "name" to searchTarget.target.conversationId)
} else {
loggerHistoryTranslation.format("list_friend_format", "name" to searchTarget.target.conversationId)
}
}
fun showExportError(throwable: Throwable) {
context.log.error("Failed to export message logger", throwable)
context.shortToast(
translation.format(
"message_logger_export_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
val messageObject = runCatching {
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
}.getOrNull()
val messageContent = messageObject?.getAsJsonObject("mMessageContent")
val contentBytes = runCatching {
messageContent?.getAsJsonArray("mContent")?.map { it.asByte }?.toByteArray()
}.getOrNull()
val contentType = messageContent?.getAsJsonPrimitive("mContentType")?.asString?.let {
runCatching { ContentType.valueOf(it) }.getOrNull()
} ?: contentBytes?.let { ContentType.fromMessageContainer(ProtoReader(it)) } ?: ContentType.UNKNOWN
val messageText = contentBytes?.getMessageText(contentType)
val attachments = runCatching {
messageContent?.let { MessageDecoder.decode(it) } ?: emptyList()
}.getOrDefault(emptyList())
return ParsedConversationMessage(
senderId = message.userId,
senderUsername = message.username,
timestamp = message.sendTimestamp,
contentType = contentType,
messageText = messageText,
attachments = attachments
)
}
fun htmlEscape(input: String): String {
val escaped = StringBuilder(input.length)
input.forEach { char ->
when (char) {
'&' -> escaped.append("&amp;")
'<' -> escaped.append("&lt;")
'>' -> escaped.append("&gt;")
'"' -> escaped.append("&quot;")
'\'' -> escaped.append("&#39;")
else -> escaped.append(char)
}
}
return escaped.toString()
}
fun writeConversationExportFile(
target: LoggerConversationExportTarget,
format: ConversationExportFormat,
outputFile: File
): Int {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
throw IllegalArgumentException("Conversation ID cannot be empty")
}
val searchTarget = exportSearchTargets.firstOrNull { it.target.conversationId == conversationId }
val conversationTitle = searchTarget?.let { formatExportTarget(it) }
?: (translation["message_logger_export_individual_chat"] ?: "Exported Chat")
val dateFormatter = DateFormat.getDateTimeInstance()
val senderCache = mutableMapOf<String, String>()
fun formatSenderLabel(senderId: String, senderUsername: String): String {
val friendInfo = context.database.getFriendInfo(senderId)
val senderDisplayName = friendInfo?.displayName?.toReadableIdentityOrNull()
val senderReadableUsername = friendInfo?.mutableUsername?.toReadableIdentityOrNull()
?: senderUsername.toReadableIdentityOrNull()
return when {
senderDisplayName != null &&
senderReadableUsername != null &&
!senderDisplayName.equals(senderReadableUsername, ignoreCase = true) ->
"$senderDisplayName (@$senderReadableUsername)"
senderDisplayName != null -> senderDisplayName
senderReadableUsername != null -> "@$senderReadableUsername"
else -> translation["sender_unknown"] ?: "Unknown sender"
}
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Failed to prepare export file")
}
return outputFile.bufferedWriter(Charsets.UTF_8).use { writer ->
val isHtmlFormat = format.extension == "html"
if (isHtmlFormat) {
writer.appendLine("<!DOCTYPE html>")
writer.appendLine("<html><head><meta charset=\"UTF-8\" />")
writer.appendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />")
writer.appendLine("<title>${htmlEscape(conversationTitle)}</title>")
writer.appendLine(
"<style>body{font-family:Arial,sans-serif;background:#121212;color:#f3f3f3;padding:16px;}h2{margin-top:0;}" +
".meta{color:#a5a5a5;font-size:12px;margin-bottom:4px;}" +
".message{border:1px solid #2d2d2d;border-radius:10px;padding:10px;margin:10px 0;background:#1b1b1b;}" +
".content{white-space:pre-wrap;word-break:break-word;}" +
".attachments{margin:8px 0 0 18px;padding:0;}a{color:#8db7ff;}</style>"
)
writer.appendLine("</head><body>")
writer.appendLine("<h2>${htmlEscape(conversationTitle)}</h2>")
writer.appendLine("<p>${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}</p>")
} else {
writer.appendLine(conversationTitle)
writer.appendLine("")
}
val exportedMessageCount = context.messageLogger.forEachConversationMessage(
conversationId = conversationId,
userIds = target.userIds,
orderAscending = true
) { loggedMessage ->
val parsed = parseConversationMessage(loggedMessage)
val senderInfo = senderCache.getOrPut(parsed.senderId) {
formatSenderLabel(parsed.senderId, parsed.senderUsername)
}
val senderLabel = senderInfo
val content = parsed.messageText?.takeIf { it.isNotBlank() } ?: if (parsed.contentType == ContentType.CHAT) {
loggerHistoryTranslation["empty_message"]
} else {
parsed.contentType.name.lowercase()
}
if (isHtmlFormat) {
writer.appendLine("<div class=\"message\">")
writer.appendLine(
"<div class=\"meta\">${
htmlEscape(
"${dateFormatter.format(Date(parsed.timestamp))}$senderLabel${
parsed.contentType.name.lowercase()
}"
)
}</div>"
)
writer.appendLine("<div class=\"content\">${htmlEscape(content).replace("\n", "<br/>")}</div>")
if (parsed.attachments.isNotEmpty()) {
writer.appendLine("<ul class=\"attachments\">")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val directUrl = attachment.directUrl?.takeIf { it.isNotBlank() }
if (directUrl != null) {
writer.appendLine(
"<li><a href=\"${htmlEscape(directUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\">${
htmlEscape(attachmentLabel)
}</a></li>"
)
} else {
val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() }
?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine("<li>${htmlEscape("$attachmentLabel: $placeholder")}</li>")
}
}
writer.appendLine("</ul>")
}
writer.appendLine("</div>")
} else {
writer.appendLine("[${dateFormatter.format(Date(parsed.timestamp))}] $senderLabel: $content")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val attachmentValue = attachment.directUrl?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine(" - $attachmentLabel: $attachmentValue")
}
writer.appendLine("")
}
}
if (exportedMessageCount == 0) {
if (isHtmlFormat) {
writer.appendLine("<p>${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}</p>")
} else {
writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")
}
}
if (isHtmlFormat) {
writer.appendLine("</body></html>")
}
exportedMessageCount
}
}
fun exportFullDatabase() {
runCatching {
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out ->
context.messageLogger.databaseFile.inputStream().use { input -> input.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream")
}
}.onFailure { showExportError(it) }
}
fun exportConversation(target: LoggerConversationExportTarget, format: ConversationExportFormat) {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
context.shortToast(translation["message_logger_missing_conversation_toast"])
return
}
val fileNameSuffix = conversationId
.filter { it.isLetterOrDigit() || it == '-' || it == '_' }
.take(24)
.ifBlank { "chat" }
runCatching {
activityLauncherHelper.saveFile("message_logger_${fileNameSuffix}.${format.extension}", format.mimeType) { uri ->
scope.launch {
runCatching {
val exportedMessageCount = withContext(Dispatchers.IO) {
val tempFile = File(
context.androidContext.cacheDir,
"message_logger_export_${System.currentTimeMillis()}.${format.extension}"
)
try {
val messageCount = if (format.extension == "db") {
context.messageLogger.exportConversationDatabase(
outputFile = tempFile,
conversationId = conversationId,
userIds = target.userIds
).messageCount
} else {
writeConversationExportFile(target, format, tempFile)
}
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { output ->
tempFile.inputStream().use { input -> input.copyTo(output) }
} ?: throw IllegalStateException("Failed to open output stream")
messageCount
} finally {
tempFile.delete()
}
}
if (exportedMessageCount == 0) {
context.shortToast(translation["message_logger_empty_chat_toast"])
} else {
context.shortToast(translation["success_toast"])
}
}.onFailure { showExportError(it) }
}
}
}.onFailure { showExportError(it) }
}
fun dismissConversationExportDialog() {
showConversationExportDialog = false
selectedConversationForExport = null
conversationSearchQuery = ""
}
fun dismissConversationFormatDialog() {
showConversationFormatDialog = false
pendingConversationExportTarget = null
}
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { showExportOptionsDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
@@ -271,6 +760,193 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
}
if (showExportOptionsDialog) {
AestheticDialog(
onDismissRequest = { showExportOptionsDialog = false },
title = translation["message_logger_export_title"] ?: "Export Message Logger",
text = translation["message_logger_export_text"] ?: "Choose what to export.",
icon = Icons.Filled.SaveAlt,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { showExportOptionsDialog = false },
showCloseButton = false,
customContent = {
Button(
onClick = {
showExportOptionsDialog = false
pendingConversationExportTarget = null
showConversationExportDialog = true
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_individual_chat"] ?: "Export Individual Chat")
}
Button(
onClick = {
showExportOptionsDialog = false
exportFullDatabase()
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_full_database"] ?: "Export Full Database")
}
}
)
}
if (showConversationExportDialog) {
AestheticDialog(
onDismissRequest = { dismissConversationExportDialog() },
title = translation["message_logger_select_chat_title"] ?: "Export Individual Chat",
text = translation["message_logger_select_chat_text"] ?: "Search by username, display name, or chat name.",
icon = Icons.Filled.Search,
confirmButtonText = translation["message_logger_continue_button"] ?: "Continue",
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
val selectedTarget = selectedConversationForExport ?: return@AestheticDialog
pendingConversationExportTarget = selectedTarget
dismissConversationExportDialog()
showConversationFormatDialog = true
},
onDismiss = { dismissConversationExportDialog() },
showCloseButton = false,
confirmEnabled = selectedConversationForExport != null,
customContent = {
OutlinedTextField(
value = conversationSearchQuery,
onValueChange = { conversationSearchQuery = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search")
},
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
trailingIcon = if (conversationSearchQuery.isNotBlank()) {
{
IconButton(onClick = { conversationSearchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null
)
}
}
} else null,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
if (filteredExportTargets.isEmpty()) {
Text(
text = translation["message_logger_no_chats_found"] ?: "No chats found",
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredExportTargets.size) { index ->
val searchTarget = filteredExportTargets[index]
val target = searchTarget.target
val isSelected = selectedConversationForExport?.conversationId == searchTarget.target.conversationId
OutlinedButton(
onClick = { selectedConversationForExport = searchTarget.target },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
border = BorderStroke(
1.dp,
if (isSelected) {
PurrfectPalette.glowPrimary.copy(alpha = 0.55f)
} else {
Color.White.copy(alpha = 0.18f)
}
)
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = formatExportTarget(searchTarget),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val secondaryLabel = when {
searchTarget.friendDisplayName != null && searchTarget.friendUsername != null -> "@${searchTarget.friendUsername}"
searchTarget.friendDisplayName != null -> searchTarget.friendDisplayName
searchTarget.chatDisplayName != null -> searchTarget.chatDisplayName
searchTarget.groupDisplayName != null -> searchTarget.groupDisplayName
searchTarget.readableUsernames.isNotEmpty() -> searchTarget.readableUsernames.joinToString(", ")
else -> null
}
if (secondaryLabel != null) {
Text(
text = secondaryLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = translation.format("message_logger_message_count", "count" to target.messageCount.toString()),
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
}
}
}
}
}
)
}
if (showConversationFormatDialog && pendingConversationExportTarget != null) {
AestheticDialog(
onDismissRequest = { dismissConversationFormatDialog() },
title = translation["message_logger_select_export_format_title"] ?: "Select Export Format",
text = translation["message_logger_select_export_format_text"] ?: "Choose how to export the selected chat.",
icon = Icons.Filled.Description,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { dismissConversationFormatDialog() },
showCloseButton = false,
customContent = {
exportFormats.forEach { format ->
val formatLabel = when (format.extension) {
"db" -> translation["message_logger_export_format_db"] ?: ".db"
"html" -> translation["message_logger_export_format_html"] ?: "HTML"
else -> translation["message_logger_export_format_txt"] ?: "TXT"
}
Button(
onClick = {
val exportTarget = pendingConversationExportTarget ?: return@Button
dismissConversationFormatDialog()
exportConversation(exportTarget, format)
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(formatLabel)
}
}
}
)
}
}
}

View File

@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.legacy
import android.os.SystemClock
import android.content.SharedPreferences
import android.content.Intent
import com.google.gson.JsonParser
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
@@ -83,15 +84,25 @@ import me.eternal.purrfectsnap.action.EnumQuickActions
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerConversationExportTarget
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.PropertyPair
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
import me.eternal.purrfectsnap.common.util.ktx.openLink
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
import me.eternal.purrfectsnap.storage.findFriend
import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.storage.getGroupInfo
import me.eternal.purrfectsnap.storage.getQuickTiles
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.storage.setQuickTiles
@@ -130,6 +141,8 @@ import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
import java.text.DateFormat
import java.util.Date
object LegacyTheme : ThemeContract {
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class)
@@ -866,11 +879,484 @@ object LegacyTheme : ThemeContract {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
var showExportOptionsDialog by remember { mutableStateOf(false) }
var showConversationExportDialog by remember { mutableStateOf(false) }
var showConversationFormatDialog by remember { mutableStateOf(false) }
var conversationSearchQuery by remember { mutableStateOf("") }
var selectedConversationForExport by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
var pendingConversationExportTarget by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
val loggerHistoryTranslation = remember { context.translation.getCategory("logger_history") }
data class ConversationSearchTarget(
val target: LoggerConversationExportTarget,
val friendDisplayName: String?,
val friendUsername: String?,
val chatDisplayName: String?,
val groupDisplayName: String?,
val readableUsernames: List<String>,
val readableIdentifiers: List<String>,
val isDirectChat: Boolean,
val isGroupChat: Boolean,
val sortOrder: Int
)
data class ConversationExportFormat(
val extension: String,
val mimeType: String,
val label: String
)
data class ParsedConversationMessage(
val senderId: String,
val senderUsername: String,
val timestamp: Long,
val contentType: ContentType,
val messageText: String?,
val attachments: List<DecodedAttachment>
)
fun String.isUuidLike(): Boolean {
val value = trim()
if (value.length != 36) return false
if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') return false
return value.filterIndexed { index, _ ->
index != 8 && index != 13 && index != 18 && index != 23
}.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
}
fun String.isLikelyInternalId(): Boolean {
val value = trim()
if (value.isUuidLike()) return true
if (value.length >= 10 && value.all(Char::isDigit)) return true
if (value.length >= 16 && value.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' || it == '-' }) {
val digitCount = value.count(Char::isDigit)
val alphaCount = value.count { it.lowercaseChar() in 'a'..'f' }
if (digitCount >= 4 && alphaCount >= 4) return true
}
return false
}
fun String.toReadableIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.isLikelyInternalId()) return null
if (!value.any { it.isLetter() }) return null
return value
}
fun String.toSearchIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.equals("myai", ignoreCase = true)) return null
return value
}
val exportTargets by rememberAsyncMutableState(defaultValue = emptyList<LoggerConversationExportTarget>()) {
context.messageLogger.getConversationExportTargets()
}
val exportSearchTargets by rememberAsyncMutableState(
defaultValue = emptyList<ConversationSearchTarget>(),
keys = arrayOf(exportTargets)
) {
val friendIdentityCache = mutableMapOf<String, Pair<String?, String?>?>()
exportTargets.mapIndexedNotNull { index, target ->
val friend = context.database.findFriend(target.conversationId)
val group = context.database.getGroupInfo(target.conversationId)
val chatDisplayName = target.groupTitle
?.toReadableIdentityOrNull()
?.takeIf { !it.equals(target.conversationId, ignoreCase = true) }
val friendDisplayName = friend?.displayName?.toReadableIdentityOrNull()
val friendUsername = friend?.mutableUsername?.toReadableIdentityOrNull()
val searchableUsernames = target.usernames
.mapNotNull { it.toSearchIdentityOrNull() }
.distinct()
val readableUsernames = searchableUsernames
.mapNotNull { it.toReadableIdentityOrNull() }
.distinct()
val hasManyParticipants = target.userIds.distinct().size > 2 || searchableUsernames.size > 2
val fallbackFriendIdentities = if (friend == null && !hasManyParticipants) {
target.userIds.mapNotNull { userId ->
friendIdentityCache.getOrPut(userId) {
context.database.getFriendInfo(userId)?.let {
it.displayName?.toReadableIdentityOrNull() to
it.mutableUsername.toReadableIdentityOrNull()
}
}?.takeIf { it.first != null || it.second != null }
}
} else {
emptyList()
}
val fallbackFriendDisplayName = fallbackFriendIdentities.firstNotNullOfOrNull { it.first }
val fallbackFriendUsername = fallbackFriendIdentities.firstNotNullOfOrNull { it.second }
val resolvedFriendDisplayName = friendDisplayName ?: fallbackFriendDisplayName
val resolvedFriendUsername = friendUsername ?: fallbackFriendUsername
val groupDisplayName = group?.name?.toReadableIdentityOrNull()
?: chatDisplayName?.takeIf { hasManyParticipants }
val isGroupChat = groupDisplayName != null || hasManyParticipants
val isDirectChat = !isGroupChat
val readableIdentifiers = buildList {
add(target.conversationId)
addAll(target.userIds)
resolvedFriendDisplayName?.let { add(it) }
resolvedFriendUsername?.let { add(it) }
chatDisplayName?.let { add(it) }
groupDisplayName?.let { add(it) }
addAll(searchableUsernames)
addAll(readableUsernames)
}.distinct()
ConversationSearchTarget(
target = target,
friendDisplayName = resolvedFriendDisplayName,
friendUsername = resolvedFriendUsername,
chatDisplayName = chatDisplayName,
groupDisplayName = groupDisplayName,
readableUsernames = readableUsernames,
readableIdentifiers = readableIdentifiers,
isDirectChat = isDirectChat,
isGroupChat = isGroupChat,
sortOrder = index
)
}.sortedWith(
compareBy<ConversationSearchTarget> {
when {
it.isDirectChat -> 0
it.isGroupChat -> 1
else -> 2
}
}.thenBy { it.sortOrder }
)
}
val filteredExportTargets = remember(exportSearchTargets, conversationSearchQuery) {
val query = conversationSearchQuery.trim()
if (query.isBlank()) {
exportSearchTargets
} else {
exportSearchTargets.filter { searchTarget ->
searchTarget.readableIdentifiers.any {
it.contains(query, ignoreCase = true)
}
}
}
}
val exportFormats = remember {
listOf(
ConversationExportFormat("db", "application/octet-stream", ".db"),
ConversationExportFormat("html", "text/html", "HTML"),
ConversationExportFormat("txt", "text/plain", "TXT")
)
}
fun formatExportTarget(searchTarget: ConversationSearchTarget): String {
searchTarget.friendDisplayName?.let { displayName ->
val username = searchTarget.friendUsername
val formattedName = if (username != null && !username.equals(displayName, ignoreCase = true)) {
"$displayName • @$username"
} else {
displayName
}
return loggerHistoryTranslation.format("list_friend_format", "name" to formattedName)
}
searchTarget.friendUsername?.let { username ->
return loggerHistoryTranslation.format("list_friend_format", "name" to "@$username")
}
searchTarget.chatDisplayName?.takeIf { searchTarget.isDirectChat }?.let {
return loggerHistoryTranslation.format("list_friend_format", "name" to it)
}
if (searchTarget.isDirectChat && searchTarget.readableUsernames.isNotEmpty()) {
val friendName = if (searchTarget.readableUsernames.size == 1) {
searchTarget.readableUsernames.first()
} else {
searchTarget.readableUsernames.joinToString(", ")
}
return loggerHistoryTranslation.format("list_friend_format", "name" to friendName)
}
searchTarget.groupDisplayName?.let {
return loggerHistoryTranslation.format("list_group_format", "name" to it)
}
if (searchTarget.readableUsernames.isNotEmpty()) {
return loggerHistoryTranslation.format(
"list_group_format",
"name" to searchTarget.readableUsernames.joinToString(", ")
)
}
return if (searchTarget.isGroupChat) {
loggerHistoryTranslation.format("list_group_format", "name" to searchTarget.target.conversationId)
} else {
loggerHistoryTranslation.format("list_friend_format", "name" to searchTarget.target.conversationId)
}
}
fun showExportError(throwable: Throwable) {
context.log.error("Failed to export message logger", throwable)
context.shortToast(
translation.format(
"message_logger_export_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
val messageObject = runCatching {
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
}.getOrNull()
val messageContent = messageObject?.getAsJsonObject("mMessageContent")
val contentBytes = runCatching {
messageContent?.getAsJsonArray("mContent")?.map { it.asByte }?.toByteArray()
}.getOrNull()
val contentType = messageContent?.getAsJsonPrimitive("mContentType")?.asString?.let {
runCatching { ContentType.valueOf(it) }.getOrNull()
} ?: contentBytes?.let { ContentType.fromMessageContainer(ProtoReader(it)) } ?: ContentType.UNKNOWN
val messageText = contentBytes?.getMessageText(contentType)
val attachments = runCatching {
messageContent?.let { MessageDecoder.decode(it) } ?: emptyList()
}.getOrDefault(emptyList())
return ParsedConversationMessage(
senderId = message.userId,
senderUsername = message.username,
timestamp = message.sendTimestamp,
contentType = contentType,
messageText = messageText,
attachments = attachments
)
}
fun htmlEscape(input: String): String {
val escaped = StringBuilder(input.length)
input.forEach { char ->
when (char) {
'&' -> escaped.append("&amp;")
'<' -> escaped.append("&lt;")
'>' -> escaped.append("&gt;")
'"' -> escaped.append("&quot;")
'\'' -> escaped.append("&#39;")
else -> escaped.append(char)
}
}
return escaped.toString()
}
fun writeConversationExportFile(
target: LoggerConversationExportTarget,
format: ConversationExportFormat,
outputFile: File
): Int {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
throw IllegalArgumentException("Conversation ID cannot be empty")
}
val searchTarget = exportSearchTargets.firstOrNull { it.target.conversationId == conversationId }
val conversationTitle = searchTarget?.let { formatExportTarget(it) }
?: (translation["message_logger_export_individual_chat"] ?: "Exported Chat")
val dateFormatter = DateFormat.getDateTimeInstance()
val senderCache = mutableMapOf<String, String>()
fun formatSenderLabel(senderId: String, senderUsername: String): String {
val friendInfo = context.database.getFriendInfo(senderId)
val senderDisplayName = friendInfo?.displayName?.toReadableIdentityOrNull()
val senderReadableUsername = friendInfo?.mutableUsername?.toReadableIdentityOrNull()
?: senderUsername.toReadableIdentityOrNull()
return when {
senderDisplayName != null &&
senderReadableUsername != null &&
!senderDisplayName.equals(senderReadableUsername, ignoreCase = true) ->
"$senderDisplayName (@$senderReadableUsername)"
senderDisplayName != null -> senderDisplayName
senderReadableUsername != null -> "@$senderReadableUsername"
else -> translation["sender_unknown"] ?: "Unknown sender"
}
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Failed to prepare export file")
}
return outputFile.bufferedWriter(Charsets.UTF_8).use { writer ->
val isHtmlFormat = format.extension == "html"
if (isHtmlFormat) {
writer.appendLine("<!DOCTYPE html>")
writer.appendLine("<html><head><meta charset=\"UTF-8\" />")
writer.appendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />")
writer.appendLine("<title>${htmlEscape(conversationTitle)}</title>")
writer.appendLine(
"<style>body{font-family:Arial,sans-serif;background:#121212;color:#f3f3f3;padding:16px;}h2{margin-top:0;}" +
".meta{color:#a5a5a5;font-size:12px;margin-bottom:4px;}" +
".message{border:1px solid #2d2d2d;border-radius:10px;padding:10px;margin:10px 0;background:#1b1b1b;}" +
".content{white-space:pre-wrap;word-break:break-word;}" +
".attachments{margin:8px 0 0 18px;padding:0;}a{color:#8db7ff;}</style>"
)
writer.appendLine("</head><body>")
writer.appendLine("<h2>${htmlEscape(conversationTitle)}</h2>")
writer.appendLine("<p>${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}</p>")
} else {
writer.appendLine(conversationTitle)
writer.appendLine("")
}
val exportedMessageCount = context.messageLogger.forEachConversationMessage(
conversationId = conversationId,
userIds = target.userIds,
orderAscending = true
) { loggedMessage ->
val parsed = parseConversationMessage(loggedMessage)
val senderInfo = senderCache.getOrPut(parsed.senderId) {
formatSenderLabel(parsed.senderId, parsed.senderUsername)
}
val senderLabel = senderInfo
val content = parsed.messageText?.takeIf { it.isNotBlank() } ?: if (parsed.contentType == ContentType.CHAT) {
loggerHistoryTranslation["empty_message"]
} else {
parsed.contentType.name.lowercase()
}
if (isHtmlFormat) {
writer.appendLine("<div class=\"message\">")
writer.appendLine(
"<div class=\"meta\">${
htmlEscape(
"${dateFormatter.format(Date(parsed.timestamp))}$senderLabel${
parsed.contentType.name.lowercase()
}"
)
}</div>"
)
writer.appendLine("<div class=\"content\">${htmlEscape(content).replace("\n", "<br/>")}</div>")
if (parsed.attachments.isNotEmpty()) {
writer.appendLine("<ul class=\"attachments\">")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val directUrl = attachment.directUrl?.takeIf { it.isNotBlank() }
if (directUrl != null) {
writer.appendLine(
"<li><a href=\"${htmlEscape(directUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\">${
htmlEscape(attachmentLabel)
}</a></li>"
)
} else {
val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() }
?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine("<li>${htmlEscape("$attachmentLabel: $placeholder")}</li>")
}
}
writer.appendLine("</ul>")
}
writer.appendLine("</div>")
} else {
writer.appendLine("[${dateFormatter.format(Date(parsed.timestamp))}] $senderLabel: $content")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val attachmentValue = attachment.directUrl?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine(" - $attachmentLabel: $attachmentValue")
}
writer.appendLine("")
}
}
if (exportedMessageCount == 0) {
if (isHtmlFormat) {
writer.appendLine("<p>${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}</p>")
} else {
writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")
}
}
if (isHtmlFormat) {
writer.appendLine("</body></html>")
}
exportedMessageCount
}
}
fun exportFullDatabase() {
runCatching {
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out ->
context.messageLogger.databaseFile.inputStream().use { input -> input.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream")
}
}.onFailure { showExportError(it) }
}
fun exportConversation(target: LoggerConversationExportTarget, format: ConversationExportFormat) {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
context.shortToast(translation["message_logger_missing_conversation_toast"])
return
}
val fileNameSuffix = conversationId
.filter { it.isLetterOrDigit() || it == '-' || it == '_' }
.take(24)
.ifBlank { "chat" }
runCatching {
activityLauncherHelper.saveFile("message_logger_${fileNameSuffix}.${format.extension}", format.mimeType) { uri ->
scope.launch {
runCatching {
val exportedMessageCount = withContext(Dispatchers.IO) {
val tempFile = File(
context.androidContext.cacheDir,
"message_logger_export_${System.currentTimeMillis()}.${format.extension}"
)
try {
val messageCount = if (format.extension == "db") {
context.messageLogger.exportConversationDatabase(
outputFile = tempFile,
conversationId = conversationId,
userIds = target.userIds
).messageCount
} else {
writeConversationExportFile(target, format, tempFile)
}
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { output ->
tempFile.inputStream().use { input -> input.copyTo(output) }
} ?: throw IllegalStateException("Failed to open output stream")
messageCount
} finally {
tempFile.delete()
}
}
if (exportedMessageCount == 0) {
context.shortToast(translation["message_logger_empty_chat_toast"])
} else {
context.shortToast(translation["success_toast"])
}
}.onFailure { showExportError(it) }
}
}
}.onFailure { showExportError(it) }
}
fun dismissConversationExportDialog() {
showConversationExportDialog = false
selectedConversationForExport = null
conversationSearchQuery = ""
}
fun dismissConversationFormatDialog() {
showConversationFormatDialog = false
pendingConversationExportTarget = null
}
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { showExportOptionsDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
@@ -880,6 +1366,193 @@ object LegacyTheme : ThemeContract {
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = importLabel, dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
}
if (showExportOptionsDialog) {
AestheticDialog(
onDismissRequest = { showExportOptionsDialog = false },
title = translation["message_logger_export_title"] ?: "Export Message Logger",
text = translation["message_logger_export_text"] ?: "Choose what to export.",
icon = Icons.Filled.SaveAlt,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { showExportOptionsDialog = false },
showCloseButton = false,
customContent = {
Button(
onClick = {
showExportOptionsDialog = false
pendingConversationExportTarget = null
showConversationExportDialog = true
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_individual_chat"] ?: "Export Individual Chat")
}
Button(
onClick = {
showExportOptionsDialog = false
exportFullDatabase()
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_full_database"] ?: "Export Full Database")
}
}
)
}
if (showConversationExportDialog) {
AestheticDialog(
onDismissRequest = { dismissConversationExportDialog() },
title = translation["message_logger_select_chat_title"] ?: "Export Individual Chat",
text = translation["message_logger_select_chat_text"] ?: "Search by username, display name, or chat name.",
icon = Icons.Filled.Search,
confirmButtonText = translation["message_logger_continue_button"] ?: "Continue",
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
val selectedTarget = selectedConversationForExport ?: return@AestheticDialog
pendingConversationExportTarget = selectedTarget
dismissConversationExportDialog()
showConversationFormatDialog = true
},
onDismiss = { dismissConversationExportDialog() },
showCloseButton = false,
confirmEnabled = selectedConversationForExport != null,
customContent = {
OutlinedTextField(
value = conversationSearchQuery,
onValueChange = { conversationSearchQuery = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search")
},
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
trailingIcon = if (conversationSearchQuery.isNotBlank()) {
{
IconButton(onClick = { conversationSearchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null
)
}
}
} else null,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
if (filteredExportTargets.isEmpty()) {
Text(
text = translation["message_logger_no_chats_found"] ?: "No chats found",
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredExportTargets.size) { index ->
val searchTarget = filteredExportTargets[index]
val target = searchTarget.target
val isSelected = selectedConversationForExport?.conversationId == searchTarget.target.conversationId
OutlinedButton(
onClick = { selectedConversationForExport = searchTarget.target },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
border = BorderStroke(
1.dp,
if (isSelected) {
PurrfectPalette.glowPrimary.copy(alpha = 0.55f)
} else {
Color.White.copy(alpha = 0.18f)
}
)
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = formatExportTarget(searchTarget),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val secondaryLabel = when {
searchTarget.friendDisplayName != null && searchTarget.friendUsername != null -> "@${searchTarget.friendUsername}"
searchTarget.friendDisplayName != null -> searchTarget.friendDisplayName
searchTarget.chatDisplayName != null -> searchTarget.chatDisplayName
searchTarget.groupDisplayName != null -> searchTarget.groupDisplayName
searchTarget.readableUsernames.isNotEmpty() -> searchTarget.readableUsernames.joinToString(", ")
else -> null
}
if (secondaryLabel != null) {
Text(
text = secondaryLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = translation.format("message_logger_message_count", "count" to target.messageCount.toString()),
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
}
}
}
}
}
)
}
if (showConversationFormatDialog && pendingConversationExportTarget != null) {
AestheticDialog(
onDismissRequest = { dismissConversationFormatDialog() },
title = translation["message_logger_select_export_format_title"] ?: "Select Export Format",
text = translation["message_logger_select_export_format_text"] ?: "Choose how to export the selected chat.",
icon = Icons.Filled.Description,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { dismissConversationFormatDialog() },
showCloseButton = false,
customContent = {
exportFormats.forEach { format ->
val formatLabel = when (format.extension) {
"db" -> translation["message_logger_export_format_db"] ?: ".db"
"html" -> translation["message_logger_export_format_html"] ?: "HTML"
else -> translation["message_logger_export_format_txt"] ?: "TXT"
}
Button(
onClick = {
val exportTarget = pendingConversationExportTarget ?: return@Button
dismissConversationFormatDialog()
exportConversation(exportTarget, format)
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(formatLabel)
}
}
}
)
}
}
}

View File

@@ -270,6 +270,26 @@
"success_toast": "Done!",
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
"export_button": "Export",
"message_logger_export_title": "Export Message Logger",
"message_logger_export_text": "Choose what to export.",
"message_logger_export_individual_chat": "Export Individual Chat",
"message_logger_export_full_database": "Export Full Database",
"message_logger_select_chat_title": "Export Individual Chat",
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
"message_logger_continue_button": "Continue",
"message_logger_select_export_format_title": "Select Export Format",
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
"message_logger_export_format_db": ".db",
"message_logger_export_format_html": "HTML",
"message_logger_export_format_txt": "TXT",
"message_logger_no_chats_found": "No chats found",
"message_logger_no_messages_export_text": "No messages found in this chat.",
"message_logger_conversation_id": "Conversation ID: {id}",
"message_logger_message_count": "{count} messages",
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
"message_logger_export_failed_toast": "Export failed: {message}",
"message_logger_missing_conversation_toast": "Missing conversation ID",
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
"import_button": "Import",
"clear_button": "Clear",
"view_logger_history_button": "View Logger History",

View File

@@ -272,6 +272,26 @@
"success_toast": "Done!",
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
"export_button": "Export",
"message_logger_export_title": "Export Message Logger",
"message_logger_export_text": "Choose what to export.",
"message_logger_export_individual_chat": "Export Individual Chat",
"message_logger_export_full_database": "Export Full Database",
"message_logger_select_chat_title": "Export Individual Chat",
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
"message_logger_continue_button": "Continue",
"message_logger_select_export_format_title": "Select Export Format",
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
"message_logger_export_format_db": ".db",
"message_logger_export_format_html": "HTML",
"message_logger_export_format_txt": "TXT",
"message_logger_no_chats_found": "No chats found",
"message_logger_no_messages_export_text": "No messages found in this chat.",
"message_logger_conversation_id": "Conversation ID: {id}",
"message_logger_message_count": "{count} messages",
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
"message_logger_export_failed_toast": "Export failed: {message}",
"message_logger_missing_conversation_toast": "Missing conversation ID",
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
"import_button": "Import",
"clear_button": "Clear",
"view_logger_history_button": "View Logger History",

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.common.bridge.wrapper
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import com.google.gson.GsonBuilder
@@ -70,10 +71,69 @@ data class TrackerLog(
}
}
data class LoggerConversationExportTarget(
val conversationId: String,
val groupTitle: String?,
val usernames: List<String>,
val userIds: List<String>,
val messageCount: Int
)
data class ConversationExportResult(
val messageCount: Int,
val chatEditCount: Int,
val trackerEventCount: Int
)
class LoggerWrapper(
val databaseFile: File,
private val readOnly: Boolean = false
): LoggerInterface.Stub() {
companion object {
private val MESSAGE_LOGGER_SCHEMA = mapOf(
"messages" to listOf(
"id INTEGER PRIMARY KEY",
"message_id BIGINT",
"conversation_id VARCHAR",
"user_id CHAR(36)",
"username VARCHAR",
"send_timestamp BIGINT",
"added_timestamp BIGINT",
"group_title VARCHAR",
"message_data BLOB"
),
"chat_edits" to listOf(
"id INTEGER PRIMARY KEY",
"edit_number INTEGER",
"added_timestamp BIGINT",
"conversation_id VARCHAR",
"message_id BIGINT",
"message_text BLOB"
),
"stories" to listOf(
"id INTEGER PRIMARY KEY",
"added_timestamp BIGINT",
"user_id VARCHAR",
"posted_timestamp BIGINT",
"created_timestamp BIGINT",
"url VARCHAR",
"encryption_key BLOB",
"encryption_iv BLOB"
),
"tracker_events" to listOf(
"id INTEGER PRIMARY KEY",
"timestamp BIGINT",
"conversation_id CHAR(36)",
"conversation_title VARCHAR",
"is_group BOOLEAN",
"username VARCHAR",
"user_id VARCHAR",
"event_type VARCHAR",
"data VARCHAR"
)
)
}
constructor(context: Context, uri: Uri? = null): this(
uri?.path?.let { File(it) } ?: File(context.getDatabasePath(InternalFileHandleType.MESSAGE_LOGGER.fileName).absolutePath),
uri != null
@@ -90,48 +150,7 @@ class LoggerWrapper(
val dbFlags = if (readOnly) SQLiteDatabase.OPEN_READONLY else SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, dbFlags)
if (!readOnly) {
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf(
"messages" to listOf(
"id INTEGER PRIMARY KEY",
"message_id BIGINT",
"conversation_id VARCHAR",
"user_id CHAR(36)",
"username VARCHAR",
"send_timestamp BIGINT",
"added_timestamp BIGINT",
"group_title VARCHAR",
"message_data BLOB"
),
"chat_edits" to listOf(
"id INTEGER PRIMARY KEY",
"edit_number INTEGER",
"added_timestamp BIGINT",
"conversation_id VARCHAR",
"message_id BIGINT",
"message_text BLOB"
),
"stories" to listOf(
"id INTEGER PRIMARY KEY",
"added_timestamp BIGINT",
"user_id VARCHAR",
"posted_timestamp BIGINT",
"created_timestamp BIGINT",
"url VARCHAR",
"encryption_key BLOB",
"encryption_iv BLOB"
),
"tracker_events" to listOf(
"id INTEGER PRIMARY KEY",
"timestamp BIGINT",
"conversation_id CHAR(36)",
"conversation_title VARCHAR",
"is_group BOOLEAN",
"username VARCHAR",
"user_id VARCHAR",
"event_type VARCHAR",
"data VARCHAR"
)
))
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, MESSAGE_LOGGER_SCHEMA)
}
_database = openedDatabase
openedDatabase
@@ -425,6 +444,223 @@ class LoggerWrapper(
return ConversationInfo(conversationId, usernames.size, groupTitle, usernames)
}
fun getConversationExportTargets(): List<LoggerConversationExportTarget> {
val groupedConversations = mutableListOf<Triple<String, String?, Int>>()
database.rawQuery(
"SELECT conversation_id, MAX(group_title) AS group_title, COUNT(*) AS message_count, MAX(send_timestamp) AS last_timestamp " +
"FROM messages WHERE conversation_id IS NOT NULL AND TRIM(conversation_id) != '' " +
"GROUP BY conversation_id ORDER BY last_timestamp DESC",
null
).use { cursor ->
while (cursor.moveToNext()) {
val conversationId = cursor.getStringOrNull("conversation_id")?.takeIf { it.isNotBlank() } ?: continue
groupedConversations.add(
Triple(
conversationId,
cursor.getStringOrNull("group_title"),
cursor.getIntOrNull("message_count") ?: 0
)
)
}
}
return groupedConversations.map { (conversationId, groupTitle, messageCount) ->
val userIds = linkedSetOf<String>()
val usernames = linkedSetOf<String>()
database.rawQuery(
"SELECT DISTINCT user_id, username FROM messages WHERE conversation_id = ?",
arrayOf(conversationId)
).use { cursor ->
while (cursor.moveToNext()) {
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { userIds.add(it) }
cursor.getStringOrNull("username")?.takeIf { it.isNotBlank() }?.let { usernames.add(it) }
}
}
LoggerConversationExportTarget(
conversationId = conversationId,
groupTitle = groupTitle,
usernames = usernames.toList(),
userIds = userIds.toList(),
messageCount = messageCount
)
}
}
fun exportConversationDatabase(
outputFile: File,
conversationId: String,
userIds: Collection<String> = emptyList()
): ConversationExportResult {
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
?: throw IllegalArgumentException("Conversation ID cannot be empty")
val normalizedUserIds = userIds
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.toSet()
.toMutableSet()
.also { ids ->
if (ids.isEmpty()) {
database.rawQuery(
"SELECT DISTINCT user_id FROM messages WHERE conversation_id = ? AND user_id IS NOT NULL AND TRIM(user_id) != ''",
arrayOf(normalizedConversationId)
).use { cursor ->
while (cursor.moveToNext()) {
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { ids.add(it) }
}
}
}
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Failed to prepare export file")
}
val outputDatabase = SQLiteDatabase.openDatabase(
outputFile.absolutePath,
null,
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
)
var transactionStarted = false
try {
SQLiteDatabaseHelper.createTablesFromSchema(outputDatabase, MESSAGE_LOGGER_SCHEMA)
outputDatabase.beginTransaction()
transactionStarted = true
val messageWhereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val messageWhereArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
val messageCount = copyQueryRows(
sourceQuery = "SELECT * FROM messages WHERE $messageWhereClause ORDER BY send_timestamp ASC",
sourceArgs = messageWhereArgs,
targetDatabase = outputDatabase,
targetTable = "messages"
)
val chatEditCount = copyQueryRows(
sourceQuery = "SELECT * FROM chat_edits WHERE conversation_id = ? AND message_id IN (SELECT message_id FROM messages WHERE $messageWhereClause) ORDER BY added_timestamp ASC",
sourceArgs = arrayOf(normalizedConversationId, *messageWhereArgs),
targetDatabase = outputDatabase,
targetTable = "chat_edits"
)
val trackerWhereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val trackerArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
val trackerEventCount = copyQueryRows(
sourceQuery = "SELECT * FROM tracker_events WHERE $trackerWhereClause ORDER BY timestamp ASC",
sourceArgs = trackerArgs,
targetDatabase = outputDatabase,
targetTable = "tracker_events"
)
outputDatabase.setTransactionSuccessful()
return ConversationExportResult(
messageCount = messageCount,
chatEditCount = chatEditCount,
trackerEventCount = trackerEventCount
)
} finally {
if (transactionStarted) {
outputDatabase.endTransaction()
}
outputDatabase.close()
}
}
private fun cursorToContentValues(cursor: Cursor): ContentValues {
return ContentValues(cursor.columnCount).apply {
for (columnIndex in 0 until cursor.columnCount) {
val columnName = cursor.getColumnName(columnIndex)
when (cursor.getType(columnIndex)) {
Cursor.FIELD_TYPE_NULL -> putNull(columnName)
Cursor.FIELD_TYPE_INTEGER -> put(columnName, cursor.getLong(columnIndex))
Cursor.FIELD_TYPE_FLOAT -> put(columnName, cursor.getDouble(columnIndex))
Cursor.FIELD_TYPE_STRING -> put(columnName, cursor.getString(columnIndex))
Cursor.FIELD_TYPE_BLOB -> put(columnName, cursor.getBlob(columnIndex))
}
}
}
}
private fun copyQueryRows(
sourceQuery: String,
sourceArgs: Array<String>? = null,
targetDatabase: SQLiteDatabase,
targetTable: String
): Int {
var rowCount = 0
database.rawQuery(sourceQuery, sourceArgs).use { cursor ->
while (cursor.moveToNext()) {
targetDatabase.insert(targetTable, null, cursorToContentValues(cursor))
rowCount++
}
}
return rowCount
}
private fun cursorToLoggedMessage(cursor: Cursor): LoggedMessage? {
return LoggedMessage(
messageId = cursor.getLongOrNull("message_id") ?: return null,
conversationId = cursor.getStringOrNull("conversation_id") ?: return null,
userId = cursor.getStringOrNull("user_id") ?: return null,
username = cursor.getStringOrNull("username") ?: return null,
sendTimestamp = cursor.getLongOrNull("send_timestamp") ?: return null,
addedTimestamp = cursor.getLongOrNull("added_timestamp") ?: return null,
groupTitle = cursor.getStringOrNull("group_title"),
messageData = cursor.getBlobOrNull("message_data") ?: return null
)
}
fun forEachConversationMessage(
conversationId: String,
userIds: Collection<String> = emptyList(),
orderAscending: Boolean = true,
block: (LoggedMessage) -> Unit
): Int {
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
?: throw IllegalArgumentException("Conversation ID cannot be empty")
val normalizedUserIds = userIds
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.toSet()
val whereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val whereArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
var total = 0
database.rawQuery(
"SELECT * FROM messages WHERE $whereClause ORDER BY send_timestamp ${if (orderAscending) "ASC" else "DESC"}",
whereArgs
).use { cursor ->
while (cursor.moveToNext()) {
cursorToLoggedMessage(cursor)?.let { loggedMessage ->
block(loggedMessage)
total++
}
}
}
return total
}
override fun getChatEdits(conversationId: String, messageId: Long): List<LoggedChatEdit> {
val edits = mutableListOf<LoggedChatEdit>()
database.rawQuery(
@@ -483,16 +719,7 @@ class LoggerWrapper(
arrayOf(conversationId, fromTimestamp.toString())
).use {
while (it.moveToNext() && messages.size < limit) {
val message = LoggedMessage(
messageId = it.getLongOrNull("message_id") ?: continue,
conversationId = it.getStringOrNull("conversation_id") ?: continue,
userId = it.getStringOrNull("user_id") ?: continue,
username = it.getStringOrNull("username") ?: continue,
sendTimestamp = it.getLongOrNull("send_timestamp") ?: continue,
addedTimestamp = it.getLongOrNull("added_timestamp") ?: continue,
groupTitle = it.getStringOrNull("group_title"),
messageData = it.getBlobOrNull("message_data") ?: continue
)
val message = cursorToLoggedMessage(it) ?: continue
if (filter != null && !filter(message)) continue
messages.add(message)
}

View File

@@ -146,13 +146,17 @@ class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.ME
it.messageId = uniqueMessageIdentifier
it.conversationId = conversationId
it.userId = event.message.senderId.toString()
it.username = usernameCache.getOrPut(it.userId) {
context.database.getFriendInfo(it.userId)?.mutableUsername ?: it.userId
}
it.username = usernameCache[it.userId]
?: context.database.getFriendInfo(it.userId)?.mutableUsername?.also { resolvedUsername ->
usernameCache[it.userId] = resolvedUsername
}
?: it.userId
it.sendTimestamp = event.message.messageMetadata?.createdAt ?: System.currentTimeMillis()
it.groupTitle = groupTitleCache.getOrPut(conversationId) {
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: conversationId
}
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)
}
)