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:
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