diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt index 861af766..70cbebb1 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt @@ -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(null) } + var pendingConversationExportTarget by remember { mutableStateOf(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, + val readableIdentifiers: List, + 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 + ) + + 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()) { + context.messageLogger.getConversationExportTargets() + } + val exportSearchTargets by rememberAsyncMutableState( + defaultValue = emptyList(), + keys = arrayOf(exportTargets) + ) { + val friendIdentityCache = mutableMapOf?>() + 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 { + 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("&") + '<' -> escaped.append("<") + '>' -> escaped.append(">") + '"' -> escaped.append(""") + '\'' -> escaped.append("'") + 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() + + 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("") + writer.appendLine("") + writer.appendLine("") + writer.appendLine("${htmlEscape(conversationTitle)}") + writer.appendLine( + "" + ) + writer.appendLine("") + writer.appendLine("

${htmlEscape(conversationTitle)}

") + writer.appendLine("

${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}

") + } 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("
") + writer.appendLine( + "
${ + htmlEscape( + "${dateFormatter.format(Date(parsed.timestamp))} • $senderLabel • ${ + parsed.contentType.name.lowercase() + }" + ) + }
" + ) + writer.appendLine("
${htmlEscape(content).replace("\n", "
")}
") + if (parsed.attachments.isNotEmpty()) { + writer.appendLine("
    ") + 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( + "
  • ${ + htmlEscape(attachmentLabel) + }
  • " + ) + } else { + val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() } + ?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() } + ?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable") + writer.appendLine("
  • ${htmlEscape("$attachmentLabel: $placeholder")}
  • ") + } + } + writer.appendLine("
") + } + writer.appendLine("
") + } 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("

${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}

") + } else { + writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.") + } + } + + if (isHtmlFormat) { + writer.appendLine("") + } + + 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) + } + } + } + ) + } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index 0b67c066..d4ea30d5 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -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 @@ -131,6 +142,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) @@ -867,11 +880,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(null) } + var pendingConversationExportTarget by remember { mutableStateOf(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, + val readableIdentifiers: List, + 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 + ) + + 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()) { + context.messageLogger.getConversationExportTargets() + } + val exportSearchTargets by rememberAsyncMutableState( + defaultValue = emptyList(), + keys = arrayOf(exportTargets) + ) { + val friendIdentityCache = mutableMapOf?>() + 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 { + 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("&") + '<' -> escaped.append("<") + '>' -> escaped.append(">") + '"' -> escaped.append(""") + '\'' -> escaped.append("'") + 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() + + 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("") + writer.appendLine("") + writer.appendLine("") + writer.appendLine("${htmlEscape(conversationTitle)}") + writer.appendLine( + "" + ) + writer.appendLine("") + writer.appendLine("

${htmlEscape(conversationTitle)}

") + writer.appendLine("

${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}

") + } 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("
") + writer.appendLine( + "
${ + htmlEscape( + "${dateFormatter.format(Date(parsed.timestamp))} • $senderLabel • ${ + parsed.contentType.name.lowercase() + }" + ) + }
" + ) + writer.appendLine("
${htmlEscape(content).replace("\n", "
")}
") + if (parsed.attachments.isNotEmpty()) { + writer.appendLine("
    ") + 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( + "
  • ${ + htmlEscape(attachmentLabel) + }
  • " + ) + } else { + val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() } + ?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() } + ?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable") + writer.appendLine("
  • ${htmlEscape("$attachmentLabel: $placeholder")}
  • ") + } + } + writer.appendLine("
") + } + writer.appendLine("
") + } 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("

${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}

") + } else { + writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.") + } + } + + if (isHtmlFormat) { + writer.appendLine("") + } + + 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"]) } @@ -881,6 +1367,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) + } + } + } + ) + } } } diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json index d56b5f81..36942344 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -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", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 0062aa93..734b9cb5 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -281,6 +281,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", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/bridge/wrapper/LoggerWrapper.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/bridge/wrapper/LoggerWrapper.kt index d72f3dac..77a2e9cb 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/bridge/wrapper/LoggerWrapper.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/bridge/wrapper/LoggerWrapper.kt @@ -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, + val userIds: List, + 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 { + val groupedConversations = mutableListOf>() + 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() + val usernames = linkedSetOf() + 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 = 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? = 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 = 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 { val edits = mutableListOf() 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) } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/MessageLogger.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/MessageLogger.kt index 7cd662b5..3a9d4feb 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/MessageLogger.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/MessageLogger.kt @@ -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) } )