feat: chat export (#17)

Co-authored-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
auth
2023-06-19 21:43:54 +02:00
committed by GitHub
parent 86abe10fa2
commit c0ed3da64e
13 changed files with 950 additions and 28 deletions

View File

@@ -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}..."
}
}

View File

@@ -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>

File diff suppressed because one or more lines are too long

View File

@@ -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"))
}
}
}

View File

@@ -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!!) }
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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() }
}
}
}

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -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)!!
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(

View File

@@ -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
}
}