commit c0ed3da64eda32753301341364620c42912903c5 parent 86abe10fa2260dbe10ee9b16082f42851bbec649 Author: auth <64337177+authorisation@users.noreply.github.com> Date: Mon, 19 Jun 2023 21:43:54 +0200 feat: chat export (#17) Co-authored-by: rhunk <101876869+rhunk@users.noreply.github.com> Diffstat:
13 files changed, 952 insertions(+), 26 deletions(-)
diff --git 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 @@ -0,0 +1,254 @@ +<style> + :root { + --sigIconPrimary: #dedede; + --sigIconSecondary: #999; + --sigIconTertiary: #616161; + --sigIconNegative: #f23c57; + --sigTextPrimary: #dedede; + --sigTextPrimaryInverse: #000; + --sigTextSecondary: #999; + --sigTextTertiary: #616161; + --sigTextPlayer: #fff; + --sigTextNegative: #f23c57; + --sigColorBackgroundBorder: rgba(255, 255, 255, 0.1); + --sigBackgroundPrimary: #121212; + --sigBackgroundPrimaryInverse: #fff; + --sigBackgroundSecondary: #1e1e1e; + --sigBackgroundSecondaryHover: #2b2b2b; + --sigBackgroundFeedHover: rgba(255, 255, 255, 0.1); + --sigBackgroundMessageHover: #292929; + --sigBackgroundMessageSaved: #333232; + --sigBackgroundMessageSavedHover: #3a3a3a; + --sigMediaControlContainerBackground: rgba(255, 255, 255, 0.1); + --sigStartupFooterBackground: rgba(0, 0, 0, 0.05); + --sigButtonPrimary: #0fadff; + --sigButtonPrimaryHover: #42bfff; + --sigButtonSecondary: #2b2b2b; + --sigButtonSecondaryHover: #424242; + --sigButtonSecondaryActive: #5c5c5c; + --sigButtonTertiary: #4e565f; + --sigButtonQuaternary: #fff; + --sigButtonInactive: #1e1e1e; + --sigButtonNegative: #e1143d; + --sigButtonOnPrimary: #fff; + --sigButtonOnSecondary: #dedede; + --sigButtonOnTertiary: #fff; + --sigButtonOnQuaternary: #1e1e1e; + --sigButtonOnInactive: rgba(255, 255, 255, 0.3); + --sigButtonOnNegative: #fff; + --sigMain: #121212; + --sigSubscreen: #121212; + --sigOverlay: rgba(0, 0, 0, 0.4); + --sigOverlayHover: rgba(0, 0, 0, 0.35); + --sigSurface: #1e1e1e; + --sigSurfaceRGB: 30, 30, 30; + --sigSurfaceDown: #212121; + --sigAboveSurface: #292929; + --sigObject: rgba(255, 255, 255, 0.1); + --sigObjectDown: rgba(255, 255, 255, 0.19); + --sigConversationBoxBackground: rgba(255, 255, 255, 0.25); + --sigDivider: rgba(255, 255, 255, 0.1); + --sigDividerLight: rgba(255, 255, 255, 0.2); + --sigPlaceholder: #1e1e1e; + --sigDisabled: rgba(255, 255, 255, 0.1); + --sigCallTileHighlight: rgba(255, 255, 255, 0.8); + --sigChat: #0fadff; + --sigSnapWithoutSound: #f23c57; + --sigSnapWithSound: #a05dcd; + --sigChatSurfaceCalling: #39ca8e; + --sigChatSurfaceCallingDisabled: #105e3d; + --sigChatPending: #767676; + --sigChatPendingHover: #8f8f8f; + --sigChatIcon: #0fadff; + --sigChatIconCaret: #f8616d; + --sigChatShadowOne: 0 0 17px rgba(33, 33, 33, 0.07), 0 0 22px rgba(0, 0, 0, 0.06), 0 0 8px rgba(84, 84, 84, 0.1); + --selectedMiddleColorGradient: rgba(4, 4, 4, 0.1); + --selectedRightColorGradient: rgba(4, 4, 4, 0); + } + + body { + margin: 0; + font-family: 'Avenir Next', sans-serif; + color: var(--sigTextPrimary); + background-color: var(--sigBackgroundPrimary); + } + + /* like an header */ + .conversation_summary { + display: flex; + flex-direction: row; + align-items: center; + padding: 10px; + border-bottom: 1px solid var(--sigColorBackgroundBorder); + } + + .conversation_message_container { + display: flex; + flex-direction: column; + padding: 5px; + background-color: var(--sigBackgroundSecondary); + } + + .message { + display: flex; + flex-direction: row; + align-items: center; + padding: 5px; + margin-left: 5px; + border-bottom: 1px solid var(--sigColorBackgroundBorder); + } + + .message .header { + display: flex; + flex-direction: column; + vertical-align: top; + align-self: flex-start; + } + + .message .username { + font-weight: bold; + } + + .message .time { + font-size: 12px; + color: var(--sigTextSecondary); + } + + .message .content { + margin-left: 10px; + display: flex; + flex-direction: row; + align-items: center; + + } + + .media_container { + max-width: 300px; + max-height: 500px; + } + + .red_snap_svg { + color: var(--sigSnapWithoutSound); + } +</style> +<body> + <div class="conversation_summary"> + <div class="title"></div> + </div> + + <div class="conversation_message_container"></div> + <div style="display: none;"> + <svg class="red_snap_svg" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect x="2.75" y="2.75" width="10.5" height="10.5" rx="1.808" stroke="currentColor" stroke-width="1.5"></rect> + </svg> + </div> + + <script> + const conversationData = JSON.parse(document.querySelector(".exported_content").innerHTML) + const participants = Object.values(conversationData.participants) + + function base64decode(data) { + return new Uint8Array(atob(data).split('').map(c => c.charCodeAt(0))) + } + + function makeConversationSummary() { + const conversationTitle = conversationData.conversationName != null ? + conversationData.conversationName : + "DM with " + Object.values(participants).map(user => user.username).join(", ") + + document.querySelector(".conversation_summary .title").textContent = conversationTitle + } + + function makeConversationMessageContainer() { + const messageTemplate = document.querySelector("#message_template") + Object.values(conversationData.messages).forEach(message => { + const messageObject = document.createElement("div") + messageObject.classList.add("message") + + messageObject.appendChild(((headerElement) =>{ + headerElement.classList.add("header") + + headerElement.appendChild(((elem) =>{ + elem.classList.add("username") + const participant = participants[message.senderId] + elem.innerHTML = (participant == null) ? "Unknown user" : participant.username + return elem + })(document.createElement("div"))) + + + headerElement.appendChild(((elem) =>{ + elem.classList.add("time") + elem.innerHTML = new Date(message.createdTimestamp).toISOString() + return elem + })(document.createElement("div"))) + + return headerElement + })(document.createElement("div"))) + + messageObject.appendChild(((elem) =>{ + elem.classList.add("content") + + elem.innerHTML = message.serializedContent + + if (!message.serializedContent) { + elem.innerHTML = "" + let messageData = "" + switch(message.type) { + case "SNAP": + elem.appendChild(document.querySelector('.red_snap_svg').cloneNode(true)) + messageData = "Snap" + break + default: + messageData = message.type + + } + elem.innerHTML += messageData + } + + if (message.mediaReferences && message.mediaReferences.length > 0) { + //only get the first reference + const reference = Object.values(message.mediaReferences)[0] + let fetched = false + var observer = new IntersectionObserver(function(entries) { + if(!fetched && entries[0].isIntersecting === true) { + fetched = true + + const mediaDiv = document.querySelector('.media-ORIGINAL_' + reference.content.replace(/(=)/g, "")) + if (!mediaDiv) return + + const content = mediaDiv.innerHTML.substring(5, mediaDiv.innerHTML.length - 4) + const decodedData = new Uint8Array(inflate(base64decode(content))) + + const blob = new Blob([decodedData]) + const url = URL.createObjectURL(blob) + + const imageTag = document.createElement("img") + imageTag.classList.add("media_container") + imageTag.src = url + imageTag.onerror = () => { + elem.removeChild(imageTag) + const mediaTag = document.createElement(message.type === "VIDEO" ? "video" : "audio") + mediaTag.classList.add("media_container") + mediaTag.src = url + mediaTag.preload = "metadata" + mediaTag.controls = true + elem.appendChild(mediaTag) + } + elem.innerHTML = "" + elem.appendChild(imageTag) + } + }, { threshold: [1] }); + observer.observe(elem) + } + + return elem + })(document.createElement("div"))) + + document.querySelector('.conversation_message_container').appendChild(messageObject) + }) + } + + makeConversationSummary() + makeConversationMessageContainer() + </script> +</body> diff --git a/app/src/main/assets/web/rawinflate.js b/app/src/main/assets/web/rawinflate.js @@ -0,0 +1,6 @@ +/* Copyright (C) 1999 Masanao Izumo <iz@onicos.co.jp> + * 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;i<this.BMAX+1;i+=1){c[i]=0}for(i=0;i<this.BMAX+1;i+=1){lx[i]=0}for(i=0;i<this.BMAX;i+=1){u[i]=null}for(i=0;i<this.N_MAX;i+=1){v[i]=0}for(i=0;i<this.BMAX+1;i+=1){x[i]=0}el=n>256?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(mm<j){mm=j}for(i=this.BMAX;i!==0;i-=1){if(c[i]!==0){break}}g=i;if(mm>i){mm=i}for(y=1<<j;j<i;j+=1,y<<=1){if((y-=c[j])<0){this.status=2;this.m=mm;return}}if((y-=c[i])<0){this.status=2;this.m=mm;return}c[i]+=y;x[1]=j=0;p=c;pidx=1;xp=2;while(--i>0){x[xp++]=(j+=p[pidx++])}p=b;pidx=0;i=0;do{if((j=p[pidx++])!==0){v[x[j]++]=i}}while(++i<n);n=x[g];x[0]=i=0;p=v;pidx=0;h=-1;w=lx[0]=0;q=null;z=0;for(null;k<=g;k+=1){a=c[k];while(a-- >0){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(++j<z){if((f<<=1)<=c[xp+=1]){break;}f-=c[xp];}}if(w+j>el&&w<el){j=el-w;}z=1<<j;lx[1+h]=j;q=[];for(o=0;o<z;o+=1){q[o]=new HuftNode()}if(!tail){tail=this.root=new HuftList()}else{tail=tail.next=new HuftList()}tail.next=null;tail.list=q;u[h]=q;if(h>0){x[h]=i;r.b=lx[h];r.e=16+j;r.t=q;j=(i&((1<<w)-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]<s){r.e=(p[pidx]<256?16:15);r.n=p[pidx++];}else{r.e=e[p[pidx]-s];r.n=d[p[pidx++]-s]}f=1<<(k-w);for(j=i>>w;j<z;j+=f){q[j].e=r.e;q[j].b=r.b;q[j].n=r.n;q[j].t=r.t}for(j=1<<(k-1);(i&j)!==0;j>>=1){i^=j}i^=j;while((i&((1<<w)-1))!==x[h]){w-=lx[h];h-=1}}}this.m=lx[1];this.status=((y!==0&&g!==1)?1:0)}function GET_BYTE(){if(inflate_data.length===inflate_pos){return -1}return inflate_data[inflate_pos++]&0xff}function NEEDBITS(n){while(bit_len<n){bit_buf|=GET_BYTE()<<bit_len;bit_len+=8}}function GETBITS(n){return bit_buf&MASK_BITS[n]}function DUMPBITS(n){bit_buf>>=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&&n<size){copy_leng-=1;copy_dist&=WSIZE-1;wp&=WSIZE-1;buff[off+n++]=slide[wp++]=slide[copy_dist++]}if(n===size){return size}}method=-1;return n}function inflate_stored(buff,off,size){var n;n=bit_len&7;DUMPBITS(n);NEEDBITS(16);n=GETBITS(16);DUMPBITS(16);NEEDBITS(16);if(n!==((~bit_buf)&0xffff)){return -1;}DUMPBITS(16);copy_leng=n;n=0;while(copy_leng>0&&n<size){copy_leng-=1;wp&=WSIZE-1;NEEDBITS(8);buff[off+n++]=slide[wp++]=GETBITS(8);DUMPBITS(8)}if(copy_leng===0){method=-1;}return n}function inflate_fixed(buff,off,size){if(!fixed_tl){var i;var l=[];var h;for(i=0;i<144;i+=1){l[i]=8}for(null;i<256;i+=1){l[i]=9}for(null;i<280;i+=1){l[i]=7}for(null;i<288;i+=1){l[i]=8}fixed_bl=7;h=new HuftBuild(l,288,257,cplens,cplext,fixed_bl);if(h.status!==0){console.error("HufBuild error: "+h.status);return -1}fixed_tl=h.root;fixed_bl=h.m;for(i=0;i<30;i+=1){l[i]=5}fixed_bd=5;h=new HuftBuild(l,30,0,cpdist,cpdext,fixed_bd);if(h.status>1){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;j<nb;j+=1){NEEDBITS(3);ll[border[j]]=GETBITS(3);DUMPBITS(3)}for(null;j<19;j+=1){ll[border[j]]=0}bl=7;h=new HuftBuild(ll,19,19,null,null,bl);if(h.status!==0){return -1;}tl=h.root;bl=h.m;n=nl+nd;i=l=0;while(i<n){NEEDBITS(bl);t=tl.list[GETBITS(bl)];j=t.b;DUMPBITS(j);j=t.n;if(j<16){ll[i++]=l=j;}else if(j===16){NEEDBITS(2);j=3+GETBITS(2);DUMPBITS(2);if(i+j>n){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;i<nd;i+=1){ll[i]=ll[i+nl]}bd=dbits;h=new HuftBuild(ll,nd,0,cpdist,cpdext,bd);td=h.root;bd=h.m;if(bd===0&&nl>257){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(n<size){if(eof&&method===-1){return n}if(copy_leng>0){if(method!==STORED_BLOCK){while(copy_leng>0&&n<size){copy_leng-=1;copy_dist&=WSIZE-1;wp&=WSIZE-1;buff[off+n++]=slide[wp++]=slide[copy_dist++]}}else{while(copy_leng>0&&n<size){copy_leng-=1;wp&=WSIZE-1;NEEDBITS(8);buff[off+n++]=slide[wp++]=GETBITS(8);DUMPBITS(8)}if(copy_leng===0){method=-1;}}if(n===size){return n}}if(method===-1){if(eof){break}NEEDBITS(1);if(GETBITS(1)!==0){eof=true}DUMPBITS(1);NEEDBITS(2);method=GETBITS(2);DUMPBITS(2);tl=null;copy_leng=0}switch(method){case STORED_BLOCK:i=inflate_stored(buff,off+n,size-n);break;case STATIC_TREES:if(tl){i=inflate_codes(buff,off+n,size-n)}else{i=inflate_fixed(buff,off+n,size-n)}break;case DYN_TREES:if(tl){i=inflate_codes(buff,off+n,size-n)}else{i=inflate_dynamic(buff,off+n,size-n)}break;default:i=-1;break}if(i===-1){if(eof){return 0}return -1}n+=i}return n}function inflate(arr){var buff=[],i;inflate_start();inflate_data=arr;inflate_pos=0;do{i=inflate_internal(buff,buff.length,1024)}while(i>0);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 @@ -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<String>() + private var currentActionDialog: AlertDialog? = null + + private var exportType: ExportFormat? = null + private var mediaToDownload: List<ContentType>? = 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<ContentType>() + 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<FriendFeedInfo>() + + 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<List<*>>(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<FriendFeedInfo>) { + dialogLogs.clear() + val jobs = mutableListOf<Job>() + + 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 @@ -12,5 +12,21 @@ class MessageMetadata(obj: Any?) : AbstractWrapper(obj){ set(value) { setEnumValue("mPlayableSnapState", value) } - val savedBy: List<SnapUUID> = (instanceNonNull().getObjectField("mSavedBy") as List<*>).map { SnapUUID(it!!) } + + private fun getUUIDList(name: String): List<SnapUUID> { + return (instanceNonNull().getObjectField(name) as List<*>).map { SnapUUID(it!!) } + } + + val savedBy: List<SnapUUID> by lazy { + getUUIDList("mSavedBy") + } + val openedBy: List<SnapUUID> by lazy { + getUUIDList("mOpenedBy") + } + val seenBy: List<SnapUUID> by lazy { + getUUIDList("mSeenBy") + } + val reactions: List<UserIdToReaction> 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 @@ -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 @@ -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 @@ -13,13 +13,14 @@ class CallbackBuilder( ) { internal class Override( val methodName: String, + val shouldUnhook: Boolean = true, val callback: (HookAdapter) -> Unit ) private val methodOverrides = mutableListOf<Override>() - 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 @@ -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 @@ -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<ContentType>? = null, + private val printLog: (String) -> Unit = {}, +) { + private lateinit var conversationParticipants: Map<String, FriendInfo> + private lateinit var messages: List<Message> + + fun readMessages(messages: List<Message>) { + 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<String, Pair<FileType, File>>()) + + 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(""" + <!DOCTYPE html> + <html> + <head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title></title> + </head> + """.trimIndent().toByteArray()) + + output.write("<!-- This file was generated by SnapEnhance ${BuildConfig.VERSION_NAME} -->\n".toByteArray()) + + mediaFiles.forEach { (key, filePair) -> + printLog("writing $key...") + output.write("<div class=\"media-$key\"><!-- ".toByteArray()) + + val deflateInputStream = DeflaterInputStream(filePair.second.inputStream(), Deflater(Deflater.BEST_COMPRESSION, true)) + val base64InputStream = XposedHelpers.newInstance( + Base64InputStream::class.java, + deflateInputStream, + android.util.Base64.DEFAULT or android.util.Base64.NO_WRAP, + true + ) as InputStream + base64InputStream.copyTo(output) + deflateInputStream.close() + + output.write(" --></div>\n".toByteArray()) + output.flush() + } + printLog("writing json conversation data...") + + //write the json file + output.write("<script type=\"application/json\" class=\"exported_content\">".toByteArray()) + exportJson(output) + output.write("</script>\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("<script>".toByteArray()) + apkFile.getInputStream(entry).copyTo(output) + output.write("</script>\n".toByteArray()) + } + + //export avenir next font + apkFile.getEntry("res/font/avenir_next_medium.ttf").let { entry -> + val encodedFontData = kotlin.io.encoding.Base64.Default.encode(apkFile.getInputStream(entry).readBytes()) + output.write(""" + <style> + @font-face { + font-family: 'Avenir Next'; + src: url('data:font/truetype;charset=utf-8;base64, $encodedFontData'); + font-weight: normal; + font-style: normal; + } + </style> + """.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("</html>".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<String, Int>() + + 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<SnapUUID>) { + 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 @@ -12,28 +12,24 @@ import javax.crypto.spec.SecretKeySpec object EncryptionHelper { fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair<ByteArray, ByteArray>? { - 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)!! + 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 - if (encryptionProtoIndex == Constants.ENCRYPTION_PROTO_INDEX_V2) { - val decoder = Base64.getMimeDecoder() - key = decoder.decode(key) - iv = decoder.decode(iv) - } + var key: ByteArray = encryptionProto.getByteArray(1)!! + var iv: ByteArray = encryptionProto.getByteArray(2)!! - return Pair(key, iv) + 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 @@ -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 Binary files differ.