From c0ed3da64eda32753301341364620c42912903c5 Mon Sep 17 00:00:00 2001
From: auth <64337177+authorisation@users.noreply.github.com>
Date: Mon, 19 Jun 2023 21:43:54 +0200
Subject: [PATCH] feat: chat export (#17)
Co-authored-by: rhunk <101876869+rhunk@users.noreply.github.com>
---
app/src/main/assets/lang/en_US.json | 20 +-
app/src/main/assets/web/export_template.html | 254 +++++++++++++
app/src/main/assets/web/rawinflate.js | 6 +
.../action/impl/ExportChatMessages.kt | 281 +++++++++++++++
.../data/wrapper/impl/MessageMetadata.kt | 18 +-
.../data/wrapper/impl/UserIdToReaction.kt | 11 +
.../snapenhance/manager/impl/ActionManager.kt | 2 +
.../rhunk/snapenhance/util/CallbackBuilder.kt | 8 +-
.../util/download/RemoteMediaResolver.kt | 2 +
.../util/export/MessageExporter.kt | 336 ++++++++++++++++++
.../snapenhance/util/snap/EncryptionHelper.kt | 38 +-
.../util/snap/MediaDownloaderHelper.kt | 2 +-
app/src/main/res/font/avenir_next_medium.ttf | Bin 0 -> 51500 bytes
13 files changed, 950 insertions(+), 28 deletions(-)
create mode 100644 app/src/main/assets/web/export_template.html
create mode 100644 app/src/main/assets/web/rawinflate.js
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/UserIdToReaction.kt
create mode 100644 app/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt
diff --git a/app/src/main/assets/lang/en_US.json b/app/src/main/assets/lang/en_US.json
index 18e3db09..f1f58cde 100644
--- a/app/src/main/assets/lang/en_US.json
+++ b/app/src/main/assets/lang/en_US.json
@@ -13,7 +13,8 @@
"clear_message_logger": "Clear Message Logger",
"refresh_mappings": "Refresh Mappings",
"open_map": "Choose location on map",
- "check_for_updates": "Check for updates"
+ "check_for_updates": "Check for updates",
+ "export_chat_messages": "Export chat messages"
},
"property": {
@@ -181,5 +182,22 @@
"dialog_negative_button": "Cancel",
"downloading_toast": "Downloading Update...",
"download_manager_notification_title": "Downloading SnapEnhance APK..."
+ },
+
+ "chat_export": {
+ "select_export_format": "Select the Export Format",
+ "select_media_type": "Select Media Types to export",
+ "select_conversation": "Select a Conversation to export",
+ "dialog_negative_button": "Cancel",
+ "dialog_neutral_button": "Export All",
+ "dialog_positive_button": "Export",
+ "exported_to": "Exported to {path}",
+ "exporting_chats": "Exporting Chats...",
+ "processing_chats": "Processing {amount} conversations...",
+ "export_fail": "Failed to export conversation {conversation}",
+ "writing_output": "Writing output...",
+ "finished": "Done! You now can close this dialog.",
+ "no_messages_found": "No messages found!",
+ "exporting_message": "Exporting {conversation}..."
}
}
\ No newline at end of file
diff --git a/app/src/main/assets/web/export_template.html b/app/src/main/assets/web/export_template.html
new file mode 100644
index 00000000..4218787f
--- /dev/null
+++ b/app/src/main/assets/web/export_template.html
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/web/rawinflate.js b/app/src/main/assets/web/rawinflate.js
new file mode 100644
index 00000000..75e782f4
--- /dev/null
+++ b/app/src/main/assets/web/rawinflate.js
@@ -0,0 +1,6 @@
+/* Copyright (C) 1999 Masanao Izumo
+ * Version: 1.0.0.1
+ * LastModified: Dec 25 1999
+ */
+
+(function(){var WSIZE=32768,STORED_BLOCK=0,STATIC_TREES=1,DYN_TREES=2,lbits=9,dbits=6,slide,wp,fixed_tl=null,fixed_td,fixed_bl,fixed_bd,bit_buf,bit_len,method,eof,copy_leng,copy_dist,tl,td,bl,bd,inflate_data,inflate_pos,MASK_BITS=[0x0000,0x0001,0x0003,0x0007,0x000f,0x001f,0x003f,0x007f,0x00ff,0x01ff,0x03ff,0x07ff,0x0fff,0x1fff,0x3fff,0x7fff,0xffff],cplens=[3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258,0,0],cplext=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,99,99],cpdist=[1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577],cpdext=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],border=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15];function HuftList(){this.next=null;this.list=null}function HuftNode(){this.e=0;this.b=0;this.n=0;this.t=null;}function HuftBuild(b,n,s,d,e,mm){this.BMAX=16;this.N_MAX=288;this.status=0;this.root=null;this.m=0;var a;var c=[];var el;var f;var g;var h;var i;var j;var k;var lx=[];var p;var pidx;var q;var r=new HuftNode();var u=[];var v=[];var w;var x=[];var xp;var y;var z;var o;var tail;tail=this.root=null;for(i=0;i256?b[256]:this.BMAX;p=b;pidx=0;i=n;do{c[p[pidx]]+=1;pidx+=1}while(--i>0);if(c[0]===n){this.root=null;this.m=0;this.status=0;return}for(j=1;j<=this.BMAX;j+=1){if(c[j]!==0){break}}k=j;if(mmi){mm=i}for(y=1<0){x[xp++]=(j+=p[pidx++])}p=b;pidx=0;i=0;do{if((j=p[pidx++])!==0){v[x[j]++]=i}}while(++i0){while(k>w+lx[1+h]){w+=lx[1+h];h+=1;z=(z=g-w)>mm?mm:z;if((f=1<<(j=k-w))>a+1){f-=a+1;xp=k;while(++jel&&w0){x[h]=i;r.b=lx[h];r.e=16+j;r.t=q;j=(i&((1<>(w-lx[h]);u[h-1][j].e=r.e;u[h-1][j].b=r.b;u[h-1][j].n=r.n;u[h-1][j].t=r.t}}r.b=k-w;if(pidx>=n){r.e=99;}else if(p[pidx]>w;j>=1){i^=j}i^=j;while((i&((1<>=n;bit_len-=n}function inflate_codes(buff,off,size){var e;var t;var n;if(size===0){return 0}n=0;for(;;){NEEDBITS(bl);t=tl.list[GETBITS(bl)];e=t.e;while(e>16){if(e===99){return -1}DUMPBITS(t.b);e-=16;NEEDBITS(e);t=t.t[GETBITS(e)];e=t.e}DUMPBITS(t.b);if(e===16){wp&=WSIZE-1;buff[off+n++]=slide[wp++]=t.n;if(n===size){return size}continue}if(e===15){break}NEEDBITS(e);copy_leng=t.n+GETBITS(e);DUMPBITS(e);NEEDBITS(bd);t=td.list[GETBITS(bd)];e=t.e;while(e>16){if(e===99){return -1}DUMPBITS(t.b);e-=16;NEEDBITS(e);t=t.t[GETBITS(e)];e=t.e}DUMPBITS(t.b);NEEDBITS(e);copy_dist=wp-t.n-GETBITS(e);DUMPBITS(e);while(copy_leng>0&&n0&&n1){fixed_tl=null;console.error("HufBuild error: "+h.status);return -1}fixed_td=h.root;fixed_bd=h.m}tl=fixed_tl;td=fixed_td;bl=fixed_bl;bd=fixed_bd;return inflate_codes(buff,off,size)}function inflate_dynamic(buff,off,size){var i;var j;var l;var n;var t;var nb;var nl;var nd;var ll=[];var h;for(i=0;i<286+30;i+=1){ll[i]=0}NEEDBITS(5);nl=257+GETBITS(5);DUMPBITS(5);NEEDBITS(5);nd=1+GETBITS(5);DUMPBITS(5);NEEDBITS(4);nb=4+GETBITS(4);DUMPBITS(4);if(nl>286||nd>30){return -1;}for(j=0;jn){return -1}while(j-- >0){ll[i++]=l}}else if(j===17){NEEDBITS(3);j=3+GETBITS(3);DUMPBITS(3);if(i+j>n){return -1}while(j-- >0){ll[i++]=0}l=0}else{NEEDBITS(7);j=11+GETBITS(7);DUMPBITS(7);if(i+j>n){return -1}while(j-- >0){ll[i++]=0}l=0}}bl=lbits;h=new HuftBuild(ll,nl,257,cplens,cplext,bl);if(bl===0){h.status=1}if(h.status!==0){if(h.status!==1){return -1;}}tl=h.root;bl=h.m;for(i=0;i257){return -1}if(h.status!==0){return -1}return inflate_codes(buff,off,size)}function inflate_start(){if(!slide){slide=[];}wp=0;bit_buf=0;bit_len=0;method=-1;eof=false;copy_leng=copy_dist=0;tl=null}function inflate_internal(buff,off,size){var n,i;n=0;while(n0){if(method!==STORED_BLOCK){while(copy_leng>0&&n0&&n0);inflate_data=null;return buff}window.inflate=inflate}());
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt
new file mode 100644
index 00000000..69907369
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/action/impl/ExportChatMessages.kt
@@ -0,0 +1,281 @@
+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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import me.rhunk.snapenhance.Logger
+import me.rhunk.snapenhance.action.AbstractAction
+import me.rhunk.snapenhance.data.ContentType
+import me.rhunk.snapenhance.data.wrapper.impl.Message
+import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
+import me.rhunk.snapenhance.database.objects.FriendFeedInfo
+import me.rhunk.snapenhance.features.impl.Messaging
+import me.rhunk.snapenhance.util.CallbackBuilder
+import me.rhunk.snapenhance.util.export.ExportFormat
+import me.rhunk.snapenhance.util.export.MessageExporter
+import java.io.File
+
+@OptIn(DelicateCoroutinesApi::class)
+class ExportChatMessages : AbstractAction("action.export_chat_messages") {
+ private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") }
+
+ private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") }
+
+ private val enterConversationMethod by lazy {
+ context.classCache.conversationManager.methods.first { it.name == "enterConversation" }
+ }
+ private val exitConversationMethod by lazy {
+ context.classCache.conversationManager.methods.first { it.name == "exitConversation" }
+ }
+ private val fetchConversationWithMessagesPaginatedMethod by lazy {
+ context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" }
+ }
+
+ private val conversationManagerInstance by lazy {
+ context.feature(Messaging::class).conversationManager
+ }
+
+ private val dialogLogs = mutableListOf()
+ private var currentActionDialog: AlertDialog? = null
+
+ private var exportType: ExportFormat? = null
+ private var mediaToDownload: List? = null
+
+ private fun logDialog(message: String) {
+ context.runOnUiThread {
+ if (dialogLogs.size > 15) dialogLogs.removeAt(0)
+ dialogLogs.add(message)
+ Logger.debug("dialog: $message")
+ currentActionDialog!!.setMessage(dialogLogs.joinToString("\n"))
+ }
+ }
+
+ private suspend fun askExportType() = suspendCancellableCoroutine { cont ->
+ context.runOnUiThread {
+ AlertDialog.Builder(context.mainActivity)
+ .setTitle(context.translation.get("chat_export.select_export_format"))
+ .setItems(ExportFormat.values().map { it.name }.toTypedArray()) { _, which ->
+ cont.resumeWith(Result.success(ExportFormat.values()[which]))
+ }
+ .setOnCancelListener {
+ cont.resumeWith(Result.success(null))
+ }
+ .show()
+ }
+ }
+
+ private suspend fun askMediaToDownload() = suspendCancellableCoroutine { cont ->
+ context.runOnUiThread {
+ val mediasToDownload = mutableListOf()
+ val contentTypes = arrayOf(
+ ContentType.SNAP,
+ ContentType.EXTERNAL_MEDIA,
+ ContentType.NOTE,
+ ContentType.STICKER
+ )
+ AlertDialog.Builder(context.mainActivity)
+ .setTitle(context.translation.get("chat_export.select_media_type"))
+ .setMultiChoiceItems(contentTypes.map { it.name }.toTypedArray(), BooleanArray(contentTypes.size) { false }) { _, which, isChecked ->
+ val media = contentTypes[which]
+ if (isChecked) {
+ mediasToDownload.add(media)
+ } else if (mediasToDownload.contains(media)) {
+ mediasToDownload.remove(media)
+ }
+ }
+ .setOnCancelListener {
+ cont.resumeWith(Result.success(null))
+ }
+ .setPositiveButton("OK") { _, _ ->
+ cont.resumeWith(Result.success(mediasToDownload))
+ }
+ .show()
+ }
+ }
+
+ override fun run() {
+ GlobalScope.launch(Dispatchers.Main) {
+ exportType = askExportType() ?: return@launch
+ mediaToDownload = if (exportType == ExportFormat.HTML) askMediaToDownload() else null
+
+ val friendFeedEntries = context.database.getFriendFeed(20)
+ val selectedConversations = mutableListOf()
+
+ AlertDialog.Builder(context.mainActivity)
+ .setTitle(context.translation.get("chat_export.select_conversation"))
+ .setMultiChoiceItems(
+ friendFeedEntries.map { it.feedDisplayName ?: it.friendDisplayName!!.split("|").firstOrNull() }.toTypedArray(),
+ BooleanArray(friendFeedEntries.size) { false }
+ ) { _, which, isChecked ->
+ if (isChecked) {
+ selectedConversations.add(friendFeedEntries[which])
+ } else if (selectedConversations.contains(friendFeedEntries[which])) {
+ selectedConversations.remove(friendFeedEntries[which])
+ }
+ }
+ .setNegativeButton(context.translation.get("chat_export.dialog_negative_button")) { dialog, _ ->
+ dialog.dismiss()
+ }
+ .setNeutralButton(context.translation.get("chat_export.dialog_neutral_button")) { _, _ ->
+ exportChatForConversations(friendFeedEntries)
+ }
+ .setPositiveButton(context.translation.get("chat_export.dialog_positive_button")) { _, _ ->
+ exportChatForConversations(selectedConversations)
+ }
+ .show()
+ }
+ }
+
+ private suspend fun conversationAction(isEntering: Boolean, conversationId: String, conversationType: String?) = suspendCancellableCoroutine { continuation ->
+ val callback = CallbackBuilder(callbackClass)
+ .override("onSuccess") { _ ->
+ continuation.resumeWith(Result.success(Unit))
+ }
+ .override("onError") {
+ continuation.resumeWith(Result.failure(Exception("Failed to ${if (isEntering) "enter" else "exit"} conversation")))
+ }.build()
+
+ if (isEntering) {
+ enterConversationMethod.invoke(
+ conversationManagerInstance,
+ SnapUUID.fromString(conversationId).instanceNonNull(),
+ enterConversationMethod.parameterTypes[1].enumConstants.first { it.toString() == conversationType },
+ callback
+ )
+ } else {
+ exitConversationMethod.invoke(
+ conversationManagerInstance,
+ SnapUUID.fromString(conversationId).instanceNonNull(),
+ Long.MAX_VALUE,
+ callback
+ )
+ }
+ }
+
+ private suspend fun fetchMessagesPaginated(conversationId: String, lastMessageId: Long) = suspendCancellableCoroutine { continuation ->
+ val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass)
+ .override("onFetchConversationWithMessagesComplete") { param ->
+ val messagesList = param.arg>(1).map { Message(it) }
+ continuation.resumeWith(Result.success(messagesList))
+ }
+ .override("onServerRequest", shouldUnhook = false) {}
+ .override("onError") {
+ continuation.resumeWith(Result.failure(Exception("Failed to fetch messages")))
+ }.build()
+
+ fetchConversationWithMessagesPaginatedMethod.invoke(
+ conversationManagerInstance,
+ SnapUUID.fromString(conversationId).instanceNonNull(),
+ lastMessageId,
+ 100,
+ callback
+ )
+ }
+
+ private suspend fun exportFullConversation(friendFeedInfo: FriendFeedInfo) {
+ //first fetch the first message
+ val conversationId = friendFeedInfo.key!!
+ val conversationName = friendFeedInfo.feedDisplayName ?: friendFeedInfo.friendDisplayName!!.split("|").lastOrNull() ?: "unknown"
+
+ conversationAction(true, conversationId, if (friendFeedInfo.feedDisplayName != null) "USERCREATEDGROUP" else "ONEONONE")
+
+ logDialog(context.translation.get("chat_export.exporting_message").replace("{conversation}", conversationName))
+
+ val foundMessages = fetchMessagesPaginated(conversationId, Long.MAX_VALUE).toMutableList()
+ var lastMessageId = foundMessages.firstOrNull()?.messageDescriptor?.messageId ?: run {
+ logDialog(context.translation.get("chat_export.no_messages_found"))
+ return
+ }
+
+ while (true) {
+ Logger.debug("[$conversationName] fetching $lastMessageId")
+ val messages = fetchMessagesPaginated(conversationId, lastMessageId)
+ if (messages.isEmpty()) break
+ foundMessages.addAll(messages)
+ messages.firstOrNull()?.let {
+ lastMessageId = it.messageDescriptor.messageId
+ }
+ }
+
+ val outputFile = File(
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+ "SnapEnhance/conversation_${conversationName}_${System.currentTimeMillis()}.${exportType!!.extension}"
+ ).also { it.parentFile?.mkdirs() }
+
+ logDialog(context.translation.get("chat_export.writing_output"))
+ MessageExporter(
+ context = context,
+ friendFeedInfo = friendFeedInfo,
+ outputFile = outputFile,
+ mediaToDownload = mediaToDownload,
+ printLog = ::logDialog
+ ).also {
+ runCatching {
+ it.readMessages(foundMessages)
+ }.onFailure {
+ logDialog(context.translation.get("chat_export.export_failed").replace("{conversation}", it.message.toString()))
+ Logger.error(it)
+ return
+ }
+ }.exportTo(exportType!!)
+
+ dialogLogs.clear()
+ logDialog("\n" + context.translation.get("chat_export.exported_to").replace("{path}", 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)
+ }
+ }
+
+ private fun exportChatForConversations(conversations: List) {
+ dialogLogs.clear()
+ val jobs = mutableListOf()
+
+ currentActionDialog = AlertDialog.Builder(context.mainActivity)
+ .setTitle(context.translation.get("chat_export.exporting_chats"))
+ .setCancelable(false)
+ .setMessage("")
+ .setNegativeButton(context.translation.get("chat_export.dialog_negative_button")) { dialog, _ ->
+ jobs.forEach { it.cancel() }
+ dialog.dismiss()
+ }
+ .create()
+
+ val conversationSize = context.translation.get("chat_export.processing_chats").replace("{amount}", conversations.size.toString())
+
+ logDialog(conversationSize)
+
+ currentActionDialog!!.show()
+
+ GlobalScope.launch(Dispatchers.Default) {
+ conversations.forEach { conversation ->
+ launch {
+ runCatching {
+ exportFullConversation(conversation)
+ }.onFailure {
+ logDialog(context.translation.get("chat_export.export_fail").replace("{conversation}", conversation.key.toString()))
+ logDialog(it.stackTraceToString())
+ Logger.xposedLog(it)
+ }
+ }.also { jobs.add(it) }
+ }
+ jobs.joinAll()
+ logDialog(context.translation.get("chat_export.finished"))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt
index ce939bad..98d954b1 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt
@@ -12,5 +12,21 @@ class MessageMetadata(obj: Any?) : AbstractWrapper(obj){
set(value) {
setEnumValue("mPlayableSnapState", value)
}
- val savedBy: List = (instanceNonNull().getObjectField("mSavedBy") as List<*>).map { SnapUUID(it!!) }
+
+ private fun getUUIDList(name: String): List {
+ return (instanceNonNull().getObjectField(name) as List<*>).map { SnapUUID(it!!) }
+ }
+
+ val savedBy: List by lazy {
+ getUUIDList("mSavedBy")
+ }
+ val openedBy: List by lazy {
+ getUUIDList("mOpenedBy")
+ }
+ val seenBy: List by lazy {
+ getUUIDList("mSeenBy")
+ }
+ val reactions: List by lazy {
+ (instanceNonNull().getObjectField("mReactions") as List<*>).map { UserIdToReaction(it!!) }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/UserIdToReaction.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/UserIdToReaction.kt
new file mode 100644
index 00000000..14312222
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/UserIdToReaction.kt
@@ -0,0 +1,11 @@
+package me.rhunk.snapenhance.data.wrapper.impl
+
+import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
+import me.rhunk.snapenhance.util.getObjectField
+
+class UserIdToReaction(obj: Any?) : AbstractWrapper(obj) {
+ val userId = SnapUUID(instanceNonNull().getObjectField("mUserId"))
+ val reactionId = (instanceNonNull().getObjectField("mReaction")
+ ?.getObjectField("mReactionContent")
+ ?.getObjectField("mIntentionType") as Long?) ?: 0
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt
index 6d8e4d1d..d1b56773 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ActionManager.kt
@@ -5,6 +5,7 @@ import me.rhunk.snapenhance.action.AbstractAction
import me.rhunk.snapenhance.action.impl.CheckForUpdates
import me.rhunk.snapenhance.action.impl.CleanCache
import me.rhunk.snapenhance.action.impl.ClearMessageLogger
+import me.rhunk.snapenhance.action.impl.ExportChatMessages
import me.rhunk.snapenhance.action.impl.OpenMap
import me.rhunk.snapenhance.action.impl.RefreshMappings
import me.rhunk.snapenhance.manager.Manager
@@ -26,6 +27,7 @@ class ActionManager(
load(RefreshMappings::class)
load(OpenMap::class)
load(CheckForUpdates::class)
+ load(ExportChatMessages::class)
actions.values.forEach(AbstractAction::init)
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt
index 64dde416..b3b82860 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt
@@ -13,13 +13,14 @@ class CallbackBuilder(
) {
internal class Override(
val methodName: String,
+ val shouldUnhook: Boolean = true,
val callback: (HookAdapter) -> Unit
)
private val methodOverrides = mutableListOf()
- fun override(methodName: String, callback: (HookAdapter) -> Unit = {}): CallbackBuilder {
- methodOverrides.add(Override(methodName, callback))
+ fun override(methodName: String, shouldUnhook: Boolean = true, callback: (HookAdapter) -> Unit = {}): CallbackBuilder {
+ methodOverrides.add(Override(methodName, shouldUnhook, callback))
return this
}
@@ -46,9 +47,7 @@ class CallbackBuilder(
//checking invokerField ensure that's the callback was created by the CallbackBuilder
if (invokerField.get(it.thisObject()) != null) return@defaultHook false
if ((it.thisObject() as Any).hashCode() != callbackInstanceHashCode) return@defaultHook false
-
it.setResult(null)
- unhooks.forEach { unhook -> unhook.unhook() }
true
}
@@ -59,6 +58,7 @@ class CallbackBuilder(
hook = {
if (defaultHook(it)) {
callback(it)
+ if (shouldUnhook) unhooks.forEach { unhook -> unhook.unhook() }
}
}
}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt
index 75e46fa3..718cef50 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/RemoteMediaResolver.kt
@@ -16,6 +16,8 @@ object RemoteMediaResolver {
private val okHttpClient = OkHttpClient.Builder()
.followRedirects(true)
+ .retryOnConnectionFailure(true)
+ .readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request()
val requestUrl = request.url.toString()
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt
new file mode 100644
index 00000000..2dfeecb7
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/export/MessageExporter.kt
@@ -0,0 +1,336 @@
+package me.rhunk.snapenhance.util.export
+
+import android.content.pm.PackageManager
+import android.os.Environment
+import android.util.Base64InputStream
+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.BuildConfig
+import me.rhunk.snapenhance.Logger
+import me.rhunk.snapenhance.ModContext
+import me.rhunk.snapenhance.data.ContentType
+import me.rhunk.snapenhance.data.FileType
+import me.rhunk.snapenhance.data.MediaReferenceType
+import me.rhunk.snapenhance.data.wrapper.impl.Message
+import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
+import me.rhunk.snapenhance.database.objects.FriendFeedInfo
+import me.rhunk.snapenhance.database.objects.FriendInfo
+import me.rhunk.snapenhance.util.protobuf.ProtoReader
+import me.rhunk.snapenhance.util.snap.EncryptionHelper
+import me.rhunk.snapenhance.util.snap.MediaDownloaderHelper
+import java.io.File
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.io.OutputStream
+import java.text.SimpleDateFormat
+import java.util.Base64
+import java.util.Collections
+import java.util.Date
+import java.util.Locale
+import java.util.zip.Deflater
+import java.util.zip.DeflaterInputStream
+import java.util.zip.ZipFile
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+
+enum class ExportFormat(
+ val extension: String,
+){
+ JSON("json"),
+ TEXT("txt"),
+ HTML("html");
+}
+
+class MessageExporter(
+ private val context: ModContext,
+ private val outputFile: File,
+ private val friendFeedInfo: FriendFeedInfo,
+ private val mediaToDownload: List? = null,
+ private val printLog: (String) -> Unit = {},
+) {
+ private lateinit var conversationParticipants: Map
+ private lateinit var messages: List
+
+ fun readMessages(messages: List) {
+ conversationParticipants =
+ context.database.getConversationParticipants(friendFeedInfo.key!!)
+ ?.mapNotNull {
+ context.database.getFriendInfo(it)
+ }?.associateBy { it.userId!! } ?: emptyMap()
+
+ if (conversationParticipants.isEmpty())
+ throw Throwable("Failed to get conversation participants for ${friendFeedInfo.key}")
+
+ this.messages = messages.sortedBy { it.orderKey }
+ }
+
+ private fun serializeMessageContent(message: Message): String? {
+ return if (message.messageContent.contentType == ContentType.CHAT) {
+ ProtoReader(message.messageContent.content).getString(2, 1) ?: "Failed to parse message"
+ } else null
+ }
+
+ private fun exportText(output: OutputStream) {
+ val writer = output.bufferedWriter()
+ writer.write("Conversation key: ${friendFeedInfo.key}\n")
+ writer.write("Conversation Name: ${friendFeedInfo.feedDisplayName}\n")
+ writer.write("Participants:\n")
+ conversationParticipants.forEach { (userId, friendInfo) ->
+ writer.write(" $userId: ${friendInfo.displayName}\n")
+ }
+
+ writer.write("\nMessages:\n")
+ messages.forEach { message ->
+ val sender = conversationParticipants[message.senderId.toString()]
+ val senderUsername = sender?.usernameForSorting ?: message.senderId.toString()
+ val senderDisplayName = sender?.displayName ?: message.senderId.toString()
+ val messageContent = serializeMessageContent(message) ?: "Failed to parse message"
+ val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(message.messageMetadata.createdAt))
+ writer.write("[$date] - $senderDisplayName (${senderUsername}): $messageContent\n")
+ }
+ writer.flush()
+ }
+
+ @OptIn(ExperimentalEncodingApi::class)
+ suspend fun exportHtml(output: OutputStream) {
+ val downloadMediaCacheFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "SnapEnhance/cache").also { it.mkdirs() }
+ val mediaFiles = Collections.synchronizedMap(mutableMapOf>())
+
+ printLog("found ${messages.size} messages")
+
+ withContext(Dispatchers.IO) {
+ messages.filter {
+ mediaToDownload?.contains(it.messageContent.contentType) ?: false
+ }.map { message ->
+ async {
+ val remoteMediaReferences by lazy {
+ val serializedMessageContent = context.gson.toJsonTree(message.messageContent.instanceNonNull()).asJsonObject
+ serializedMessageContent["mRemoteMediaReferences"]
+ .asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray }
+ .flatten()
+ }
+
+ remoteMediaReferences.firstOrNull().takeIf { it != null }?.let { media ->
+ val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray()
+
+ runCatching {
+ val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) {
+ 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("=", "")}"
+
+ val mediaFile = File(downloadMediaCacheFolder, "$fileName.${fileType.fileExtension}")
+
+ FileOutputStream(mediaFile).use { fos ->
+ mediaData.inputStream().copyTo(fos)
+ }
+
+ mediaFiles[fileName] = fileType to mediaFile
+ }
+ }.onFailure {
+ printLog("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}")
+ Logger.error("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}", it)
+ }
+ }
+ }
+ }.awaitAll()
+ }
+
+ printLog("writing downloaded medias...")
+
+ //write the head of the html file
+ output.write("""
+
+
+
+
+
+
+
+
+ """.trimIndent().toByteArray())
+
+ output.write("\n".toByteArray())
+
+ mediaFiles.forEach { (key, filePair) ->
+ printLog("writing $key...")
+ output.write("\n".toByteArray())
+ output.flush()
+ }
+ printLog("writing json conversation data...")
+
+ //write the json file
+ output.write("\n".toByteArray())
+
+ printLog("writing template...")
+
+ runCatching {
+ ZipFile(
+ context.androidContext.packageManager.getApplicationInfo(BuildConfig.APPLICATION_ID, PackageManager.GET_META_DATA).publicSourceDir
+ ).use { apkFile ->
+ //export rawinflate.js
+ apkFile.getEntry("assets/web/rawinflate.js").let { entry ->
+ output.write("\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("""
+
+ """.trimIndent().toByteArray())
+ }
+
+ apkFile.getEntry("assets/web/export_template.html").let { entry ->
+ apkFile.getInputStream(entry).copyTo(output)
+ }
+
+ apkFile.close()
+ }
+ }.onFailure {
+ printLog("failed to read template from apk")
+ Logger.error("failed to read template from apk", it)
+ }
+
+ output.write("".toByteArray())
+ output.close()
+ printLog("done")
+ }
+
+ private fun exportJson(output: OutputStream) {
+ val rootObject = JsonObject().apply {
+ addProperty("conversationId", friendFeedInfo.key)
+ addProperty("conversationName", friendFeedInfo.feedDisplayName)
+
+ var index = 0
+ val participants = mutableMapOf()
+
+ add("participants", JsonObject().apply {
+ conversationParticipants.forEach { (userId, friendInfo) ->
+ add(userId, JsonObject().apply {
+ addProperty("id", index)
+ addProperty("displayName", friendInfo.displayName)
+ addProperty("username", friendInfo.usernameForSorting)
+ addProperty("bitmojiSelfieId", friendInfo.bitmojiSelfieId)
+ })
+ participants[userId] = index++
+ }
+ })
+ add("messages", JsonArray().apply {
+ messages.forEach { message ->
+ add(JsonObject().apply {
+ addProperty("orderKey", message.orderKey)
+ addProperty("senderId", participants.getOrDefault(message.senderId.toString(), -1))
+ addProperty("type", message.messageContent.contentType.toString())
+
+ fun addUUIDList(name: String, list: List) {
+ add(name, JsonArray().apply {
+ list.map { participants.getOrDefault(it.toString(), -1) }.forEach { add(it) }
+ })
+ }
+
+ addUUIDList("savedBy", message.messageMetadata.savedBy)
+ addUUIDList("seenBy", message.messageMetadata.seenBy)
+ addUUIDList("openedBy", message.messageMetadata.openedBy)
+
+ add("reactions", JsonObject().apply {
+ message.messageMetadata.reactions.forEach { reaction ->
+ addProperty(
+ participants.getOrDefault(reaction.userId.toString(), -1L).toString(),
+ reaction.reactionId
+ )
+ }
+ })
+
+ addProperty("createdTimestamp", message.messageMetadata.createdAt)
+ addProperty("readTimestamp", message.messageMetadata.readAt)
+ addProperty("serializedContent", serializeMessageContent(message))
+ addProperty("rawContent", Base64.getUrlEncoder().encodeToString(message.messageContent.content))
+
+ val messageContentType = message.messageContent.contentType
+
+ EncryptionHelper.getEncryptionKeys(messageContentType, ProtoReader(message.messageContent.content), isArroyo = false)?.let { encryptionKeyPair ->
+ add("encryption", JsonObject().apply encryption@{
+ addProperty("key", Base64.getEncoder().encodeToString(encryptionKeyPair.first))
+ addProperty("iv", Base64.getEncoder().encodeToString(encryptionKeyPair.second))
+ })
+ }
+
+ val remoteMediaReferences by lazy {
+ val serializedMessageContent = context.gson.toJsonTree(message.messageContent.instanceNonNull()).asJsonObject
+ serializedMessageContent["mRemoteMediaReferences"]
+ .asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray }
+ .flatten()
+ }
+
+ add("mediaReferences", JsonArray().apply mediaReferences@ {
+ if (messageContentType != ContentType.EXTERNAL_MEDIA &&
+ messageContentType != ContentType.STICKER &&
+ messageContentType != ContentType.SNAP &&
+ messageContentType != ContentType.NOTE)
+ return@mediaReferences
+
+ remoteMediaReferences.forEach { media ->
+ val protoMediaReference = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray()
+ val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString)
+ add(JsonObject().apply {
+ addProperty("mediaType", mediaType.toString())
+ addProperty("content", Base64.getUrlEncoder().encodeToString(protoMediaReference))
+ })
+ }
+ })
+
+ })
+ }
+ })
+ }
+
+ output.write(context.gson.toJson(rootObject).toByteArray())
+ output.flush()
+ }
+
+ suspend fun exportTo(exportFormat: ExportFormat) {
+ val output = FileOutputStream(outputFile)
+
+ when (exportFormat) {
+ ExportFormat.HTML -> exportHtml(output)
+ ExportFormat.JSON -> exportJson(output)
+ ExportFormat.TEXT -> exportText(output)
+ }
+
+ output.close()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt
index f6bac98c..82e6f61b 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/EncryptionHelper.kt
@@ -12,28 +12,24 @@ import javax.crypto.spec.SecretKeySpec
object EncryptionHelper {
fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair? {
- val messageMediaInfo =
- MediaDownloaderHelper.getMessageMediaInfo(messageProto, contentType, isArroyo)
-
- return messageMediaInfo?.let { mediaEncryption ->
- val encryptionProtoIndex: Int = if (mediaEncryption.exists(Constants.ENCRYPTION_PROTO_INDEX_V2)) {
- Constants.ENCRYPTION_PROTO_INDEX_V2
- } else {
- Constants.ENCRYPTION_PROTO_INDEX
- }
-
- val encryptionProto = mediaEncryption.readPath(encryptionProtoIndex) ?: return null
- var key: ByteArray = encryptionProto.getByteArray(1)!!
- var iv: ByteArray = encryptionProto.getByteArray(2)!!
-
- if (encryptionProtoIndex == Constants.ENCRYPTION_PROTO_INDEX_V2) {
- val decoder = Base64.getMimeDecoder()
- key = decoder.decode(key)
- iv = decoder.decode(iv)
- }
-
- return Pair(key, iv)
+ val messageMediaInfo = MediaDownloaderHelper.getMessageMediaInfo(messageProto, contentType, isArroyo) ?: return null
+ val encryptionProtoIndex = if (messageMediaInfo.exists(Constants.ENCRYPTION_PROTO_INDEX_V2)) {
+ Constants.ENCRYPTION_PROTO_INDEX_V2
+ } else {
+ Constants.ENCRYPTION_PROTO_INDEX
}
+ val encryptionProto = messageMediaInfo.readPath(encryptionProtoIndex) ?: return null
+
+ var key: ByteArray = encryptionProto.getByteArray(1)!!
+ var iv: ByteArray = encryptionProto.getByteArray(2)!!
+
+ if (encryptionProtoIndex == Constants.ENCRYPTION_PROTO_INDEX_V2) {
+ val decoder = Base64.getMimeDecoder()
+ key = decoder.decode(key)
+ iv = decoder.decode(iv)
+ }
+
+ return Pair(key, iv)
}
fun decryptInputStream(
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt
index cd197e8a..8fb8e220 100644
--- a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/MediaDownloaderHelper.kt
@@ -28,7 +28,7 @@ object MediaDownloaderHelper {
ContentType.NOTE -> messageContainerPath.readPath(*mediaContainerPath)
ContentType.SNAP -> messageContainerPath.readPath(*(intArrayOf(11) + mediaContainerPath))
ContentType.EXTERNAL_MEDIA -> messageContainerPath.readPath(*(intArrayOf(3, 3) + mediaContainerPath))
- else -> throw IllegalArgumentException("Invalid content type: $contentType")
+ else -> null
}
}
diff --git a/app/src/main/res/font/avenir_next_medium.ttf b/app/src/main/res/font/avenir_next_medium.ttf
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..41f5d1ea02adcc4f0464e7fd337730f6c26d0101 100644
GIT binary patch
literal 51500
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
b{7iAnPMo}hlYKY|?mT=WP&N=#iqd}oAV$x0
literal 0
HcmV?d00001