Merge pull request #134 from schrodingerspet
feat: Add message logger export individual chat.
This commit is contained in:
@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
|||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import com.google.gson.JsonParser
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
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.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import me.eternal.purrfectsnap.R
|
import me.eternal.purrfectsnap.R
|
||||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
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.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.getAllScopeNotes
|
||||||
|
import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||||
|
import me.eternal.purrfectsnap.storage.getGroupInfo
|
||||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||||
@@ -60,6 +74,8 @@ import androidx.compose.ui.platform.LocalView
|
|||||||
import androidx.core.view.drawToBitmap
|
import androidx.core.view.drawToBitmap
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -257,11 +273,484 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
|
|||||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
||||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
||||||
var showImportDialog by remember { mutableStateOf(false) }
|
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("&")
|
||||||
|
'<' -> 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<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) {
|
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", " | ")
|
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())
|
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) ) {
|
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 { 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 = { 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"]) }
|
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) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.legacy
|
|||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import com.google.gson.JsonParser
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
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.BuildConfig
|
||||||
import me.eternal.purrfectsnap.common.action.EnumAction
|
import me.eternal.purrfectsnap.common.action.EnumAction
|
||||||
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
|
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.ConfigContainer
|
||||||
import me.eternal.purrfectsnap.common.config.PropertyPair
|
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.data.SocialScope
|
||||||
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
||||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||||
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
|
||||||
import me.eternal.purrfectsnap.common.util.ktx.openLink
|
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.getAllScopeNotes
|
||||||
|
import me.eternal.purrfectsnap.storage.getFriendInfo
|
||||||
|
import me.eternal.purrfectsnap.storage.getGroupInfo
|
||||||
import me.eternal.purrfectsnap.storage.getQuickTiles
|
import me.eternal.purrfectsnap.storage.getQuickTiles
|
||||||
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
import me.eternal.purrfectsnap.storage.setAllScopeNotes
|
||||||
import me.eternal.purrfectsnap.storage.setQuickTiles
|
import me.eternal.purrfectsnap.storage.setQuickTiles
|
||||||
@@ -131,6 +142,8 @@ import okhttp3.Request
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
object LegacyTheme : ThemeContract {
|
object LegacyTheme : ThemeContract {
|
||||||
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@@ -867,11 +880,484 @@ object LegacyTheme : ThemeContract {
|
|||||||
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
|
||||||
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
|
||||||
var showImportDialog by remember { mutableStateOf(false) }
|
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("&")
|
||||||
|
'<' -> 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<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) {
|
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", " | ")
|
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())
|
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) ) {
|
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 { 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 = { 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"]) }
|
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) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,26 @@
|
|||||||
"success_toast": "Done!",
|
"success_toast": "Done!",
|
||||||
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
|
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
|
||||||
"export_button": "Export",
|
"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",
|
"import_button": "Import",
|
||||||
"clear_button": "Clear",
|
"clear_button": "Clear",
|
||||||
"view_logger_history_button": "View Logger History",
|
"view_logger_history_button": "View Logger History",
|
||||||
|
|||||||
@@ -281,6 +281,26 @@
|
|||||||
"success_toast": "Done!",
|
"success_toast": "Done!",
|
||||||
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
|
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
|
||||||
"export_button": "Export",
|
"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",
|
"import_button": "Import",
|
||||||
"clear_button": "Clear",
|
"clear_button": "Clear",
|
||||||
"view_logger_history_button": "View Logger History",
|
"view_logger_history_button": "View Logger History",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.common.bridge.wrapper
|
|||||||
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.google.gson.GsonBuilder
|
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(
|
class LoggerWrapper(
|
||||||
val databaseFile: File,
|
val databaseFile: File,
|
||||||
private val readOnly: Boolean = false
|
private val readOnly: Boolean = false
|
||||||
): LoggerInterface.Stub() {
|
): 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(
|
constructor(context: Context, uri: Uri? = null): this(
|
||||||
uri?.path?.let { File(it) } ?: File(context.getDatabasePath(InternalFileHandleType.MESSAGE_LOGGER.fileName).absolutePath),
|
uri?.path?.let { File(it) } ?: File(context.getDatabasePath(InternalFileHandleType.MESSAGE_LOGGER.fileName).absolutePath),
|
||||||
uri != null
|
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 dbFlags = if (readOnly) SQLiteDatabase.OPEN_READONLY else SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
|
||||||
val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, dbFlags)
|
val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, dbFlags)
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf(
|
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, MESSAGE_LOGGER_SCHEMA)
|
||||||
"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"
|
|
||||||
)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
_database = openedDatabase
|
_database = openedDatabase
|
||||||
openedDatabase
|
openedDatabase
|
||||||
@@ -425,6 +444,223 @@ class LoggerWrapper(
|
|||||||
return ConversationInfo(conversationId, usernames.size, groupTitle, usernames)
|
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> {
|
override fun getChatEdits(conversationId: String, messageId: Long): List<LoggedChatEdit> {
|
||||||
val edits = mutableListOf<LoggedChatEdit>()
|
val edits = mutableListOf<LoggedChatEdit>()
|
||||||
database.rawQuery(
|
database.rawQuery(
|
||||||
@@ -483,16 +719,7 @@ class LoggerWrapper(
|
|||||||
arrayOf(conversationId, fromTimestamp.toString())
|
arrayOf(conversationId, fromTimestamp.toString())
|
||||||
).use {
|
).use {
|
||||||
while (it.moveToNext() && messages.size < limit) {
|
while (it.moveToNext() && messages.size < limit) {
|
||||||
val message = LoggedMessage(
|
val message = cursorToLoggedMessage(it) ?: continue
|
||||||
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
|
|
||||||
)
|
|
||||||
if (filter != null && !filter(message)) continue
|
if (filter != null && !filter(message)) continue
|
||||||
messages.add(message)
|
messages.add(message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,13 +146,17 @@ class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.ME
|
|||||||
it.messageId = uniqueMessageIdentifier
|
it.messageId = uniqueMessageIdentifier
|
||||||
it.conversationId = conversationId
|
it.conversationId = conversationId
|
||||||
it.userId = event.message.senderId.toString()
|
it.userId = event.message.senderId.toString()
|
||||||
it.username = usernameCache.getOrPut(it.userId) {
|
it.username = usernameCache[it.userId]
|
||||||
context.database.getFriendInfo(it.userId)?.mutableUsername ?: 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.sendTimestamp = event.message.messageMetadata?.createdAt ?: System.currentTimeMillis()
|
||||||
it.groupTitle = groupTitleCache.getOrPut(conversationId) {
|
it.groupTitle = groupTitleCache[conversationId]
|
||||||
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: conversationId
|
?: context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName?.also { resolvedGroupTitle ->
|
||||||
}
|
groupTitleCache[conversationId] = resolvedGroupTitle
|
||||||
|
}
|
||||||
|
?: conversationId
|
||||||
it.messageData = context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)
|
it.messageData = context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user