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