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 e69de29b..41f5d1ea 100644
Binary files a/app/src/main/res/font/avenir_next_medium.ttf and b/app/src/main/res/font/avenir_next_medium.ttf differ