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