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

This commit is contained in:
rhunk 2023-09-02 12:32:24 +02:00
parent 58f4f51fe6
commit 33131728ca
4 changed files with 154 additions and 109 deletions

View File

@ -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",

View File

@ -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()
}
}

View File

@ -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...")
threadPool.shutdown()
threadPool.awaitTermination(30, TimeUnit.DAYS)
processCount = 0
//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())
printLog("writing downloaded medias...")
output.write("<!-- This file was generated by SnapEnhance ${BuildConfig.VERSION_NAME} -->\n".toByteArray())
//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())
mediaFiles.forEach { (key, filePair) ->
printLog("writing $key...")
output.write("<div class=\"media-$key\"><!-- ".toByteArray())
output.write("<!-- This file was generated by SnapEnhance ${BuildConfig.VERSION_NAME} -->\n".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()
mediaFiles.forEach { (key, filePair) ->
output.write("<div class=\"media-$key\"><!-- ".toByteArray())
output.write(" --></div>\n".toByteArray())
output.flush()
}
printLog("writing json conversation data...")
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()
//write the json file
output.write("<script type=\"application/json\" class=\"exported_content\">".toByteArray())
exportJson(output)
output.write("</script>\n".toByteArray())
output.write(" --></div>\n".toByteArray())
output.flush()
updateProgress("wrote")
}
printLog("writing json conversation data...")
printLog("writing template...")
//write the json file
output.write("<script type=\"application/json\" class=\"exported_content\">".toByteArray())
exportJson(output)
output.write("</script>\n".toByteArray())
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())
}
printLog("writing template...")
//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("""
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("""
<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) {

View File

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