mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-11 11:54:32 +02:00
feat(export_chat_messages): ability to select the amount of message
This commit is contained in:
parent
58f4f51fe6
commit
33131728ca
@ -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",
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user