commit 33131728cadbb3e203d17ddec4bf2814b714137c
parent 58f4f51fe69cec02a273dfe082d09eb08372bdce
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat,  2 Sep 2023 12:32:24 +0200

feat(export_chat_messages): ability to select the amount of message

Diffstat:
Mcore/src/main/assets/lang/en_US.json | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt | 109+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt | 162++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt | 7+++++--
4 files changed, 162 insertions(+), 117 deletions(-)

diff --git a/core/src/main/assets/lang/en_US.json b/core/src/main/assets/lang/en_US.json @@ -587,6 +587,7 @@ "chat_export": { "select_export_format": "Select the Export Format", "select_media_type": "Select Media Types to export", + "select_amount_of_messages": "Select the amount of messages to export (leave empty for all)", "select_conversation": "Select a Conversation to export", "dialog_negative_button": "Cancel", "dialog_neutral_button": "Export All", diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt b/core/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt @@ -2,12 +2,11 @@ package me.rhunk.snapenhance.action.impl import android.app.AlertDialog import android.content.DialogInterface -import android.content.Intent -import android.net.Uri import android.os.Environment -import kotlinx.coroutines.DelicateCoroutinesApi +import android.text.InputType +import android.widget.EditText +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -24,8 +23,8 @@ import me.rhunk.snapenhance.util.CallbackBuilder import me.rhunk.snapenhance.util.export.ExportFormat import me.rhunk.snapenhance.util.export.MessageExporter import java.io.File +import kotlin.math.absoluteValue -@OptIn(DelicateCoroutinesApi::class) class ExportChatMessages : AbstractAction() { private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") } @@ -45,21 +44,30 @@ class ExportChatMessages : AbstractAction() { context.feature(Messaging::class).conversationManager } + private val coroutineScope = CoroutineScope(Dispatchers.Default) + private val dialogLogs = mutableListOf<String>() private var currentActionDialog: AlertDialog? = null private var exportType: ExportFormat? = null private var mediaToDownload: List<ContentType>? = null + private var amountOfMessages: Int? = null private fun logDialog(message: String) { context.runOnUiThread { - if (dialogLogs.size > 15) dialogLogs.removeAt(0) + if (dialogLogs.size > 10) dialogLogs.removeAt(0) dialogLogs.add(message) - context.log.debug("dialog: $message") + context.log.debug("dialog: $message", "ExportChatMessages") currentActionDialog!!.setMessage(dialogLogs.joinToString("\n")) } } + private fun setStatus(message: String) { + context.runOnUiThread { + currentActionDialog!!.setTitle(message) + } + } + private suspend fun askExportType() = suspendCancellableCoroutine { cont -> context.runOnUiThread { ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) @@ -74,6 +82,26 @@ class ExportChatMessages : AbstractAction() { } } + private suspend fun askAmountOfMessages() = suspendCancellableCoroutine { cont -> + coroutineScope.launch(Dispatchers.Main) { + val input = EditText(context.mainActivity) + input.inputType = InputType.TYPE_CLASS_NUMBER + input.setSingleLine() + input.maxLines = 1 + + ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) + .setTitle(context.translation["chat_export.select_amount_of_messages"]) + .setView(input) + .setPositiveButton(context.translation["button.ok"]) { _, _ -> + cont.resumeWith(Result.success(input.text.takeIf { it.isNotEmpty() }?.toString()?.toIntOrNull()?.absoluteValue)) + } + .setOnCancelListener { + cont.resumeWith(Result.success(null)) + } + .show() + } + } + private suspend fun askMediaToDownload() = suspendCancellableCoroutine { cont -> context.runOnUiThread { val mediasToDownload = mutableListOf<ContentType>() @@ -96,7 +124,7 @@ class ExportChatMessages : AbstractAction() { .setOnCancelListener { cont.resumeWith(Result.success(null)) } - .setPositiveButton("OK") { _, _ -> + .setPositiveButton(context.translation["button.ok"]) { _, _ -> cont.resumeWith(Result.success(mediasToDownload)) } .show() @@ -104,11 +132,12 @@ class ExportChatMessages : AbstractAction() { } override fun run() { - GlobalScope.launch(Dispatchers.Main) { + coroutineScope.launch(Dispatchers.Main) { exportType = askExportType() ?: return@launch mediaToDownload = if (exportType == ExportFormat.HTML) askMediaToDownload() else null + amountOfMessages = askAmountOfMessages() - val friendFeedEntries = context.database.getFeedEntries(20) + val friendFeedEntries = context.database.getFeedEntries(500) val selectedConversations = mutableListOf<FriendFeedEntry>() ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity) @@ -177,7 +206,7 @@ class ExportChatMessages : AbstractAction() { conversationManagerInstance, SnapUUID.fromString(conversationId).instanceNonNull(), lastMessageId, - 100, + 500, callback ) } @@ -200,10 +229,17 @@ class ExportChatMessages : AbstractAction() { while (true) { val messages = fetchMessagesPaginated(conversationId, lastMessageId) if (messages.isEmpty()) break + + if (amountOfMessages != null && messages.size + foundMessages.size >= amountOfMessages!!) { + foundMessages.addAll(messages.take(amountOfMessages!! - foundMessages.size)) + break + } + foundMessages.addAll(messages) messages.firstOrNull()?.let { lastMessageId = it.messageDescriptor.messageId } + setStatus("Exporting (${foundMessages.size} / ${foundMessages.firstOrNull()?.orderKey})") } val outputFile = File( @@ -212,33 +248,26 @@ class ExportChatMessages : AbstractAction() { ).also { it.parentFile?.mkdirs() } logDialog(context.translation["chat_export.writing_output"]) - MessageExporter( - context = context, - friendFeedEntry = friendFeedEntry, - outputFile = outputFile, - mediaToDownload = mediaToDownload, - printLog = ::logDialog - ).also { - runCatching { - it.readMessages(foundMessages) - }.onFailure { - logDialog(context.translation.format("chat_export.export_failed","conversation" to it.message.toString())) - context.log.error("Failed to read messages", it) - return - } - }.exportTo(exportType!!) + + runCatching { + MessageExporter( + context = context, + friendFeedEntry = friendFeedEntry, + outputFile = outputFile, + mediaToDownload = mediaToDownload, + printLog = ::logDialog + ).apply { readMessages(foundMessages) }.exportTo(exportType!!) + }.onFailure { + logDialog(context.translation.format("chat_export.export_failed","conversation" to it.message.toString())) + context.log.error("Failed to export conversation $conversationName", it) + return + } dialogLogs.clear() logDialog("\n" + context.translation.format("chat_export.exported_to", "path" to outputFile.absolutePath.toString() ) + "\n") - currentActionDialog?.setButton(DialogInterface.BUTTON_POSITIVE, "Open") { _, _ -> - val intent = Intent(Intent.ACTION_VIEW) - intent.setDataAndType(Uri.fromFile(outputFile.parentFile), "resource/folder") - context.mainActivity!!.startActivity(intent) - } - runCatching { conversationAction(false, conversationId, null) } @@ -252,19 +281,13 @@ class ExportChatMessages : AbstractAction() { .setTitle(context.translation["chat_export.exporting_chats"]) .setCancelable(false) .setMessage("") - .setNegativeButton(context.translation["chat_export.dialog_negative_button"]) { dialog, _ -> - jobs.forEach { it.cancel() } - dialog.dismiss() - } .create() val conversationSize = context.translation.format("chat_export.processing_chats", "amount" to conversations.size.toString()) logDialog(conversationSize) - currentActionDialog!!.show() - - GlobalScope.launch(Dispatchers.Default) { + coroutineScope.launch { conversations.forEach { conversation -> launch { runCatching { @@ -278,6 +301,14 @@ class ExportChatMessages : AbstractAction() { } jobs.joinAll() logDialog(context.translation["chat_export.finished"]) + }.also { + currentActionDialog?.setButton(DialogInterface.BUTTON_POSITIVE, context.translation["chat_export.dialog_negative_button"]) { dialog, _ -> + it.cancel() + jobs.forEach { it.cancel() } + dialog.dismiss() + } } + + currentActionDialog!!.show() } } \ No newline at end of file diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt @@ -6,8 +6,6 @@ import com.google.gson.JsonArray import com.google.gson.JsonObject import de.robv.android.xposed.XposedHelpers import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.core.BuildConfig @@ -30,6 +28,8 @@ import java.util.Base64 import java.util.Collections import java.util.Date import java.util.Locale +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.zip.Deflater import java.util.zip.DeflaterInputStream import java.util.zip.ZipFile @@ -98,14 +98,23 @@ class MessageExporter( suspend fun exportHtml(output: OutputStream) { val downloadMediaCacheFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance/cache").also { it.mkdirs() } val mediaFiles = Collections.synchronizedMap(mutableMapOf<String, Pair<FileType, File>>()) - - printLog("found ${messages.size} messages") + val threadPool = Executors.newFixedThreadPool(15) withContext(Dispatchers.IO) { + var processCount = 0 + + fun updateProgress(type: String) { + val total = messages.filter { + mediaToDownload?.contains(it.messageContent.contentType) ?: false + }.size + processCount++ + printLog("$type $processCount/$total") + } + messages.filter { mediaToDownload?.contains(it.messageContent.contentType) ?: false - }.map { message -> - async { + }.forEach { message -> + threadPool.execute { val remoteMediaReferences by lazy { val serializedMessageContent = context.gson.toJsonTree(message.messageContent.instanceNonNull()).asJsonObject serializedMessageContent["mRemoteMediaReferences"] @@ -121,8 +130,6 @@ class MessageExporter( EncryptionHelper.decryptInputStream(it, message.messageContent.contentType!!, ProtoReader(message.messageContent.content), isArroyo = false) } - printLog("downloaded media ${message.orderKey}") - downloadedMedia.forEach { (type, mediaData) -> val fileType = FileType.fromByteArray(mediaData) val fileName = "${type}_${kotlin.io.encoding.Base64.UrlSafe.encode(protoMediaReference).replace("=", "")}" @@ -134,6 +141,7 @@ class MessageExporter( } mediaFiles[fileName] = fileType to mediaFile + updateProgress("downloaded") } }.onFailure { printLog("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}") @@ -141,64 +149,67 @@ class MessageExporter( } } } - }.awaitAll() - } + } - printLog("writing downloaded medias...") - - //write the head of the html file - output.write(""" - <!DOCTYPE html> - <html> - <head> - <meta charset="UTF-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title></title> - </head> - """.trimIndent().toByteArray()) - - output.write("<!-- This file was generated by SnapEnhance ${BuildConfig.VERSION_NAME} -->\n".toByteArray()) - - mediaFiles.forEach { (key, filePair) -> - printLog("writing $key...") - output.write("<div class=\"media-$key\"><!-- ".toByteArray()) - - val deflateInputStream = DeflaterInputStream(filePair.second.inputStream(), Deflater(Deflater.BEST_COMPRESSION, true)) - val base64InputStream = XposedHelpers.newInstance( - Base64InputStream::class.java, - deflateInputStream, - android.util.Base64.DEFAULT or android.util.Base64.NO_WRAP, - true - ) as InputStream - base64InputStream.copyTo(output) - deflateInputStream.close() - - output.write(" --></div>\n".toByteArray()) - output.flush() - } - printLog("writing json conversation data...") - - //write the json file - output.write("<script type=\"application/json\" class=\"exported_content\">".toByteArray()) - exportJson(output) - output.write("</script>\n".toByteArray()) - - printLog("writing template...") - - runCatching { - ZipFile(context.bridgeClient.getApplicationApkPath()).use { apkFile -> - //export rawinflate.js - apkFile.getEntry("assets/web/rawinflate.js").let { entry -> - output.write("<script>".toByteArray()) - apkFile.getInputStream(entry).copyTo(output) - output.write("</script>\n".toByteArray()) - } + threadPool.shutdown() + threadPool.awaitTermination(30, TimeUnit.DAYS) + processCount = 0 + + printLog("writing downloaded medias...") + + //write the head of the html file + output.write(""" + <!DOCTYPE html> + <html> + <head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title></title> + </head> + """.trimIndent().toByteArray()) + + output.write("<!-- This file was generated by SnapEnhance ${BuildConfig.VERSION_NAME} -->\n".toByteArray()) + + mediaFiles.forEach { (key, filePair) -> + output.write("<div class=\"media-$key\"><!-- ".toByteArray()) + + val deflateInputStream = DeflaterInputStream(filePair.second.inputStream(), Deflater(Deflater.BEST_COMPRESSION, true)) + val base64InputStream = XposedHelpers.newInstance( + Base64InputStream::class.java, + deflateInputStream, + android.util.Base64.DEFAULT or android.util.Base64.NO_WRAP, + true + ) as InputStream + base64InputStream.copyTo(output) + deflateInputStream.close() + + output.write(" --></div>\n".toByteArray()) + output.flush() + updateProgress("wrote") + } + printLog("writing json conversation data...") + + //write the json file + output.write("<script type=\"application/json\" class=\"exported_content\">".toByteArray()) + exportJson(output) + output.write("</script>\n".toByteArray()) + + printLog("writing template...") + + runCatching { + ZipFile(context.bridgeClient.getApplicationApkPath()).use { apkFile -> + //export rawinflate.js + apkFile.getEntry("assets/web/rawinflate.js").let { entry -> + output.write("<script>".toByteArray()) + apkFile.getInputStream(entry).copyTo(output) + output.write("</script>\n".toByteArray()) + } - //export avenir next font - apkFile.getEntry("res/font/avenir_next_medium.ttf").let { entry -> - val encodedFontData = kotlin.io.encoding.Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) - output.write(""" + //export avenir next font + apkFile.getEntry("res/font/avenir_next_medium.ttf").let { entry -> + val encodedFontData = kotlin.io.encoding.Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) + output.write(""" <style> @font-face { font-family: 'Avenir Next'; @@ -208,22 +219,21 @@ class MessageExporter( } </style> """.trimIndent().toByteArray()) - } + } - apkFile.getEntry("assets/web/export_template.html").let { entry -> - apkFile.getInputStream(entry).copyTo(output) - } + apkFile.getEntry("assets/web/export_template.html").let { entry -> + apkFile.getInputStream(entry).copyTo(output) + } - apkFile.close() + apkFile.close() + } + }.onFailure { + throw Throwable("Failed to read template from apk", it) } - }.onFailure { - printLog("failed to read template from apk") - context.log.error("failed to read template from apk", it) - } - output.write("</html>".toByteArray()) - output.close() - printLog("done") + output.write("</html>".toByteArray()) + output.close() + } } private fun exportJson(output: OutputStream) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt @@ -36,8 +36,11 @@ object MediaDownloaderHelper { } } - fun downloadMediaFromReference(mediaReference: ByteArray, decryptionCallback: (InputStream) -> InputStream): Map<SplitMediaAssetType, ByteArray> { - val inputStream: InputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info") + fun downloadMediaFromReference( + mediaReference: ByteArray, + decryptionCallback: (InputStream) -> InputStream, + ): Map<SplitMediaAssetType, ByteArray> { + val inputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info") val content = decryptionCallback(inputStream).readBytes() val fileType = FileType.fromByteArray(content) val isZipFile = fileType == FileType.ZIP