feat: chat export (#17)
Co-authored-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
@@ -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}..."
|
||||
}
|
||||
}
|
||||
254
app/src/main/assets/web/export_template.html
Normal file
254
app/src/main/assets/web/export_template.html
Normal 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>
|
||||
6
app/src/main/assets/web/rawinflate.js
Normal file
6
app/src/main/assets/web/rawinflate.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!!) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user