mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-12 05:07:46 +02:00
feat: multiple media chat export
- optimize message exporter download - optimize zip download/extract
This commit is contained in:
@ -122,11 +122,16 @@
|
||||
|
||||
}
|
||||
|
||||
.media_container {
|
||||
.chat_media {
|
||||
max-width: 300px;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.overlay_media {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.red_snap_svg {
|
||||
color: var(--sigSnapWithoutSound);
|
||||
}
|
||||
@ -140,7 +145,7 @@
|
||||
<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>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@ -152,13 +157,24 @@
|
||||
}
|
||||
|
||||
function makeConversationSummary() {
|
||||
const conversationTitle = conversationData.conversationName != null ?
|
||||
conversationData.conversationName :
|
||||
"DM with " + Object.values(participants).map(user => user.username).join(", ")
|
||||
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 decodeMedia(element) {
|
||||
const decodedData = new Uint8Array(
|
||||
inflate(
|
||||
base64decode(
|
||||
element.innerHTML.substring(5, element.innerHTML.length - 4)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return URL.createObjectURL(new Blob([decodedData]))
|
||||
}
|
||||
|
||||
function makeConversationMessageContainer() {
|
||||
const messageTemplate = document.querySelector("#message_template")
|
||||
Object.values(conversationData.messages).forEach(message => {
|
||||
@ -185,63 +201,88 @@
|
||||
return headerElement
|
||||
})(document.createElement("div")))
|
||||
|
||||
messageObject.appendChild(((elem) =>{
|
||||
elem.classList.add("content")
|
||||
messageObject.appendChild(((messageContainer) =>{
|
||||
messageContainer.classList.add("content")
|
||||
|
||||
elem.innerHTML = message.serializedContent
|
||||
messageContainer.innerHTML = message.serializedContent
|
||||
|
||||
if (!message.serializedContent) {
|
||||
elem.innerHTML = ""
|
||||
messageContainer.innerHTML = ""
|
||||
let messageData = ""
|
||||
switch(message.type) {
|
||||
case "SNAP":
|
||||
elem.appendChild(document.querySelector('.red_snap_svg').cloneNode(true))
|
||||
messageContainer.appendChild(document.querySelector('.red_snap_svg').cloneNode(true))
|
||||
messageData = "Snap"
|
||||
break
|
||||
default:
|
||||
messageData = message.type
|
||||
|
||||
}
|
||||
elem.innerHTML += messageData
|
||||
messageContainer.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
|
||||
if (message.attachments && message.attachments.length > 0) {
|
||||
let observers = []
|
||||
|
||||
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)))
|
||||
message.attachments.forEach((attachment, index) => {
|
||||
const mediaKey = attachment.key.replace(/(=)/g, "")
|
||||
|
||||
observers.push(() => {
|
||||
const originalMedia = document.querySelector('.media-ORIGINAL_' + mediaKey)
|
||||
if (!originalMedia) {
|
||||
return
|
||||
}
|
||||
|
||||
const originalMediaUrl = decodeMedia(originalMedia)
|
||||
|
||||
const mediaContainer = document.createElement("div")
|
||||
messageContainer.appendChild(mediaContainer)
|
||||
|
||||
const blob = new Blob([decodedData])
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const imageTag = document.createElement("img")
|
||||
imageTag.classList.add("media_container")
|
||||
imageTag.src = url
|
||||
imageTag.src = originalMediaUrl
|
||||
imageTag.classList.add("chat_media")
|
||||
mediaContainer.appendChild(imageTag)
|
||||
|
||||
imageTag.onerror = () => {
|
||||
elem.removeChild(imageTag)
|
||||
mediaContainer.removeChild(imageTag)
|
||||
const mediaTag = document.createElement(message.type === "NOTE" ? "audio" : "video")
|
||||
mediaTag.classList.add("media_container")
|
||||
mediaTag.src = url
|
||||
mediaTag.classList.add("chat_media")
|
||||
mediaTag.src = originalMediaUrl
|
||||
mediaTag.preload = "metadata"
|
||||
mediaTag.controls = true
|
||||
elem.appendChild(mediaTag)
|
||||
mediaContainer.appendChild(mediaTag)
|
||||
}
|
||||
elem.innerHTML = ""
|
||||
elem.appendChild(imageTag)
|
||||
|
||||
const overlay = document.querySelector('.media-OVERLAY_' + mediaKey)
|
||||
if (!overlay) {
|
||||
return
|
||||
}
|
||||
|
||||
const overlayImage = document.createElement("img")
|
||||
overlayImage.src = decodeMedia(overlay)
|
||||
overlayImage.classList.add("chat_media")
|
||||
overlayImage.classList.add("overlay_media")
|
||||
mediaContainer.appendChild(overlayImage)
|
||||
})
|
||||
})
|
||||
|
||||
let fetched = false
|
||||
|
||||
new IntersectionObserver(entries => {
|
||||
if(!fetched && entries[0].isIntersecting === true) {
|
||||
fetched = true
|
||||
messageContainer.innerHTML = ""
|
||||
observers.forEach(c => {
|
||||
try {
|
||||
c()
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { threshold: [1] });
|
||||
observer.observe(elem)
|
||||
}).observe(messageContainer)
|
||||
}
|
||||
|
||||
return elem
|
||||
return messageContainer
|
||||
})(document.createElement("div")))
|
||||
|
||||
document.querySelector('.conversation_message_container').appendChild(messageObject)
|
||||
|
@ -3,6 +3,11 @@
|
||||
package me.rhunk.snapenhance.core.download.data
|
||||
|
||||
import me.rhunk.snapenhance.data.wrapper.impl.media.EncryptionWrapper
|
||||
import java.io.InputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ -10,12 +15,16 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
data class MediaEncryptionKeyPair(
|
||||
val key: String,
|
||||
val iv: String
|
||||
)
|
||||
|
||||
fun Pair<ByteArray, ByteArray>.toKeyPair(): MediaEncryptionKeyPair {
|
||||
return MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.first), Base64.UrlSafe.encode(this.second))
|
||||
) {
|
||||
fun decryptInputStream(inputStream: InputStream): InputStream {
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(Base64.UrlSafe.decode(key), "AES"), IvParameterSpec(Base64.UrlSafe.decode(iv)))
|
||||
return CipherInputStream(inputStream, cipher)
|
||||
}
|
||||
}
|
||||
|
||||
fun EncryptionWrapper.toKeyPair(): MediaEncryptionKeyPair {
|
||||
return MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.keySpec), Base64.UrlSafe.encode(this.ivKeyParameterSpec))
|
||||
}
|
||||
fun Pair<ByteArray, ByteArray>.toKeyPair()
|
||||
= MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.first), Base64.UrlSafe.encode(this.second))
|
||||
|
||||
fun EncryptionWrapper.toKeyPair()
|
||||
= MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.keySpec), Base64.UrlSafe.encode(this.ivKeyParameterSpec))
|
@ -4,7 +4,6 @@ import me.rhunk.snapenhance.Constants
|
||||
import me.rhunk.snapenhance.core.Logger
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.Base64
|
||||
|
||||
@ -36,18 +35,34 @@ object RemoteMediaResolver {
|
||||
}
|
||||
.build()
|
||||
|
||||
fun downloadBoltMedia(protoKey: ByteArray): InputStream? {
|
||||
val request = Request.Builder()
|
||||
.url(BOLT_HTTP_RESOLVER_URL + "/resolve?co=" + Base64.getUrlEncoder().encodeToString(protoKey))
|
||||
.addHeader("User-Agent", Constants.USER_AGENT)
|
||||
.build()
|
||||
private fun newResolveRequest(protoKey: ByteArray) = Request.Builder()
|
||||
.url(BOLT_HTTP_RESOLVER_URL + "/resolve?co=" + Base64.getUrlEncoder().encodeToString(protoKey))
|
||||
.addHeader("User-Agent", Constants.USER_AGENT)
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
/**
|
||||
* Download bolt media with memory allocation
|
||||
*/
|
||||
fun downloadBoltMedia(protoKey: ByteArray, decryptionCallback: (InputStream) -> InputStream = { it }): ByteArray? {
|
||||
okHttpClient.newCall(newResolveRequest(protoKey)).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
Logger.directDebug("Unexpected code $response")
|
||||
return null
|
||||
}
|
||||
return ByteArrayInputStream(response.body.bytes())
|
||||
return decryptionCallback(response.body.byteStream()).readBytes()
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadBoltMedia(protoKey: ByteArray, decryptionCallback: (InputStream) -> InputStream = { it }, resultCallback: (InputStream) -> Unit) {
|
||||
okHttpClient.newCall(newResolveRequest(protoKey)).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw Throwable("invalid response ${response.code}")
|
||||
}
|
||||
resultCallback(
|
||||
decryptionCallback(
|
||||
response.body.byteStream()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package me.rhunk.snapenhance.core.util.export
|
||||
import android.os.Environment
|
||||
import android.util.Base64InputStream
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonNull
|
||||
import com.google.gson.JsonObject
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -11,20 +12,21 @@ import me.rhunk.snapenhance.ModContext
|
||||
import me.rhunk.snapenhance.core.BuildConfig
|
||||
import me.rhunk.snapenhance.core.database.objects.FriendFeedEntry
|
||||
import me.rhunk.snapenhance.core.database.objects.FriendInfo
|
||||
import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver
|
||||
import me.rhunk.snapenhance.core.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.core.util.snap.EncryptionHelper
|
||||
import me.rhunk.snapenhance.core.util.snap.MediaDownloaderHelper
|
||||
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.features.impl.downloader.decoder.AttachmentType
|
||||
import me.rhunk.snapenhance.features.impl.downloader.decoder.MessageDecoder
|
||||
import java.io.BufferedInputStream
|
||||
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
|
||||
@ -33,6 +35,7 @@ import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.DeflaterInputStream
|
||||
import java.util.zip.ZipFile
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
|
||||
@ -44,6 +47,7 @@ enum class ExportFormat(
|
||||
HTML("html");
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class MessageExporter(
|
||||
private val context: ModContext,
|
||||
private val outputFile: File,
|
||||
@ -94,7 +98,6 @@ class MessageExporter(
|
||||
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>>())
|
||||
@ -115,34 +118,30 @@ class MessageExporter(
|
||||
mediaToDownload?.contains(it.messageContent.contentType) ?: false
|
||||
}.forEach { message ->
|
||||
threadPool.execute {
|
||||
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()
|
||||
MessageDecoder.decode(message.messageContent).forEach decode@{ attachment ->
|
||||
val protoMediaReference = Base64.UrlSafe.decode(attachment.mediaKey ?: return@decode)
|
||||
|
||||
runCatching {
|
||||
val downloadedMedia = MediaDownloaderHelper.downloadMediaFromReference(protoMediaReference) {
|
||||
EncryptionHelper.decryptInputStream(it, message.messageContent.contentType!!, ProtoReader(message.messageContent.content), isArroyo = false)
|
||||
}
|
||||
RemoteMediaResolver.downloadBoltMedia(protoMediaReference, decryptionCallback = {
|
||||
(attachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it)
|
||||
}) {
|
||||
it.use { inputStream ->
|
||||
MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream ->
|
||||
val fileName = "${type}_${Base64.UrlSafe.encode(protoMediaReference).replace("=", "")}"
|
||||
val bufferedInputStream = BufferedInputStream(splitInputStream)
|
||||
val fileType = MediaDownloaderHelper.getFileType(bufferedInputStream)
|
||||
val mediaFile = File(downloadMediaCacheFolder, "$fileName.${fileType.fileExtension}")
|
||||
|
||||
downloadedMedia.forEach { (type, mediaData) ->
|
||||
val fileType = FileType.fromByteArray(mediaData)
|
||||
val fileName = "${type}_${kotlin.io.encoding.Base64.UrlSafe.encode(protoMediaReference).replace("=", "")}"
|
||||
FileOutputStream(mediaFile).use { fos ->
|
||||
bufferedInputStream.copyTo(fos)
|
||||
}
|
||||
|
||||
val mediaFile = File(downloadMediaCacheFolder, "$fileName.${fileType.fileExtension}")
|
||||
|
||||
FileOutputStream(mediaFile).use { fos ->
|
||||
mediaData.inputStream().copyTo(fos)
|
||||
mediaFiles[fileName] = fileType to mediaFile
|
||||
}
|
||||
}
|
||||
|
||||
mediaFiles[fileName] = fileType to mediaFile
|
||||
updateProgress("downloaded")
|
||||
}
|
||||
|
||||
updateProgress("downloaded")
|
||||
}.onFailure {
|
||||
printLog("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}")
|
||||
context.log.error("failed to download media for ${message.messageDescriptor.conversationId}_${message.orderKey}", it)
|
||||
@ -208,7 +207,7 @@ class MessageExporter(
|
||||
|
||||
//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())
|
||||
val encodedFontData = Base64.Default.encode(apkFile.getInputStream(entry).readBytes())
|
||||
output.write("""
|
||||
<style>
|
||||
@font-face {
|
||||
@ -284,41 +283,25 @@ class MessageExporter(
|
||||
addProperty("createdTimestamp", message.messageMetadata.createdAt)
|
||||
addProperty("readTimestamp", message.messageMetadata.readAt)
|
||||
addProperty("serializedContent", serializeMessageContent(message))
|
||||
addProperty("rawContent", Base64.getUrlEncoder().encodeToString(message.messageContent.content))
|
||||
addProperty("rawContent", Base64.UrlSafe.encode(message.messageContent.content))
|
||||
|
||||
val messageContentType = message.messageContent.contentType ?: ContentType.CHAT
|
||||
|
||||
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("attachments", JsonArray().apply {
|
||||
MessageDecoder.decode(message.messageContent)
|
||||
.forEach attachments@{ attachments ->
|
||||
if (attachments.type == AttachmentType.STICKER) //TODO: implement stickers
|
||||
return@attachments
|
||||
add(JsonObject().apply {
|
||||
addProperty("mediaType", mediaType.toString())
|
||||
addProperty("content", Base64.getUrlEncoder().encodeToString(protoMediaReference))
|
||||
addProperty("key", attachments.mediaKey?.replace("=", ""))
|
||||
addProperty("type", attachments.type.toString())
|
||||
add("encryption", attachments.attachmentInfo?.encryption?.let { encryption ->
|
||||
JsonObject().apply {
|
||||
addProperty("key", encryption.key)
|
||||
addProperty("iv", encryption.iv)
|
||||
}
|
||||
} ?: JsonNull.INSTANCE)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
})
|
||||
|
@ -1,73 +0,0 @@
|
||||
package me.rhunk.snapenhance.core.util.snap
|
||||
|
||||
import me.rhunk.snapenhance.Constants
|
||||
import me.rhunk.snapenhance.core.download.data.MediaEncryptionKeyPair
|
||||
import me.rhunk.snapenhance.core.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.data.ContentType
|
||||
import java.io.InputStream
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.CipherInputStream
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
object EncryptionHelper {
|
||||
fun getEncryptionKeys(contentType: ContentType, messageProto: ProtoReader, isArroyo: Boolean): Pair<ByteArray, ByteArray>? {
|
||||
val mediaEncryptionInfo = MediaDownloaderHelper.getMessageMediaEncryptionInfo(
|
||||
messageProto,
|
||||
contentType,
|
||||
isArroyo
|
||||
) ?: return null
|
||||
val encryptionProtoIndex = if (mediaEncryptionInfo.contains(Constants.ENCRYPTION_PROTO_INDEX_V2)) {
|
||||
Constants.ENCRYPTION_PROTO_INDEX_V2
|
||||
} else {
|
||||
Constants.ENCRYPTION_PROTO_INDEX
|
||||
}
|
||||
val encryptionProto = mediaEncryptionInfo.followPath(encryptionProtoIndex) ?: return null
|
||||
|
||||
var key: ByteArray = encryptionProto.getByteArray(1)!!
|
||||
var iv: ByteArray = encryptionProto.getByteArray(2)!!
|
||||
|
||||
if (encryptionProtoIndex == Constants.ENCRYPTION_PROTO_INDEX_V2) {
|
||||
key = Base64.UrlSafe.decode(key)
|
||||
iv = Base64.UrlSafe.decode(iv)
|
||||
}
|
||||
|
||||
return Pair(key, iv)
|
||||
}
|
||||
|
||||
fun decryptInputStream(
|
||||
inputStream: InputStream,
|
||||
contentType: ContentType,
|
||||
messageProto: ProtoReader,
|
||||
isArroyo: Boolean
|
||||
): InputStream {
|
||||
val encryptionKeys = getEncryptionKeys(contentType, messageProto, isArroyo) ?: throw Exception("Failed to get encryption keys")
|
||||
|
||||
Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.DECRYPT_MODE, SecretKeySpec(encryptionKeys.first, "AES"), IvParameterSpec(encryptionKeys.second))
|
||||
}.let { cipher ->
|
||||
return CipherInputStream(inputStream, cipher)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptInputStream(
|
||||
inputStream: InputStream,
|
||||
mediaEncryptionKeyPair: MediaEncryptionKeyPair?
|
||||
): InputStream {
|
||||
if (mediaEncryptionKeyPair == null) {
|
||||
return inputStream
|
||||
}
|
||||
|
||||
Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
|
||||
init(Cipher.DECRYPT_MODE,
|
||||
SecretKeySpec(Base64.UrlSafe.decode(mediaEncryptionKeyPair.key), "AES"),
|
||||
IvParameterSpec(Base64.UrlSafe.decode(mediaEncryptionKeyPair.iv))
|
||||
)
|
||||
}.let { cipher ->
|
||||
return CipherInputStream(inputStream, cipher)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,70 +1,45 @@
|
||||
package me.rhunk.snapenhance.core.util.snap
|
||||
|
||||
import me.rhunk.snapenhance.Constants
|
||||
import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType
|
||||
import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver
|
||||
import me.rhunk.snapenhance.core.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.data.ContentType
|
||||
import me.rhunk.snapenhance.data.FileType
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
|
||||
object MediaDownloaderHelper {
|
||||
fun getMessageMediaEncryptionInfo(protoReader: ProtoReader, contentType: ContentType, isArroyo: Boolean): ProtoReader? {
|
||||
val messageContainerPath = if (isArroyo) protoReader.followPath(*Constants.ARROYO_MEDIA_CONTAINER_PROTO_PATH)!! else protoReader
|
||||
val mediaContainerPath = if (contentType == ContentType.NOTE) intArrayOf(6, 1, 1) else intArrayOf(5, 1, 1)
|
||||
|
||||
return when (contentType) {
|
||||
ContentType.NOTE -> messageContainerPath.followPath(*mediaContainerPath)
|
||||
ContentType.SNAP -> messageContainerPath.followPath(*(intArrayOf(11) + mediaContainerPath))
|
||||
ContentType.EXTERNAL_MEDIA -> {
|
||||
val externalMediaTypes = arrayOf(
|
||||
intArrayOf(3, 3, *mediaContainerPath), //normal external media
|
||||
intArrayOf(7, 15, 1, 1), //attached audio note
|
||||
intArrayOf(7, 12, 3, *mediaContainerPath), //attached story reply
|
||||
intArrayOf(7, 3, *mediaContainerPath), //original story reply
|
||||
)
|
||||
externalMediaTypes.forEach { path ->
|
||||
messageContainerPath.followPath(*path)?.also { return it }
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
fun getFileType(bufferedInputStream: BufferedInputStream): FileType {
|
||||
val buffer = ByteArray(16)
|
||||
bufferedInputStream.mark(16)
|
||||
bufferedInputStream.read(buffer)
|
||||
bufferedInputStream.reset()
|
||||
return FileType.fromByteArray(buffer)
|
||||
}
|
||||
|
||||
fun downloadMediaFromReference(
|
||||
mediaReference: ByteArray,
|
||||
decryptionCallback: (InputStream) -> InputStream,
|
||||
): Map<SplitMediaAssetType, ByteArray> {
|
||||
val inputStream = RemoteMediaResolver.downloadBoltMedia(mediaReference) ?: throw FileNotFoundException("Unable to get media key. Check the logs for more info")
|
||||
val content = decryptionCallback(inputStream).readBytes()
|
||||
val fileType = FileType.fromByteArray(content)
|
||||
val isZipFile = fileType == FileType.ZIP
|
||||
|
||||
//videos with overlay are packed in a zip file
|
||||
//there are 2 files in the zip file, the video (webm) and the overlay (png)
|
||||
if (isZipFile) {
|
||||
var videoData: ByteArray? = null
|
||||
var overlayData: ByteArray? = null
|
||||
val zipInputStream = ZipInputStream(ByteArrayInputStream(content))
|
||||
while (zipInputStream.nextEntry != null) {
|
||||
val zipEntryData: ByteArray = zipInputStream.readBytes()
|
||||
val entryFileType = FileType.fromByteArray(zipEntryData)
|
||||
if (entryFileType.isVideo) {
|
||||
videoData = zipEntryData
|
||||
} else if (entryFileType.isImage) {
|
||||
overlayData = zipEntryData
|
||||
}
|
||||
}
|
||||
videoData ?: throw FileNotFoundException("Unable to find video file in zip file")
|
||||
overlayData ?: throw FileNotFoundException("Unable to find overlay file in zip file")
|
||||
return mapOf(SplitMediaAssetType.ORIGINAL to videoData, SplitMediaAssetType.OVERLAY to overlayData)
|
||||
fun getSplitElements(
|
||||
inputStream: InputStream,
|
||||
callback: (SplitMediaAssetType, InputStream) -> Unit
|
||||
) {
|
||||
val bufferedInputStream = BufferedInputStream(inputStream)
|
||||
val fileType = getFileType(bufferedInputStream)
|
||||
|
||||
if (fileType != FileType.ZIP) {
|
||||
callback(SplitMediaAssetType.ORIGINAL, bufferedInputStream)
|
||||
return
|
||||
}
|
||||
|
||||
return mapOf(SplitMediaAssetType.ORIGINAL to content)
|
||||
val zipInputStream = ZipInputStream(bufferedInputStream)
|
||||
|
||||
var entry: ZipEntry? = zipInputStream.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name.startsWith("overlay")) {
|
||||
callback(SplitMediaAssetType.OVERLAY, zipInputStream)
|
||||
} else if (entry.name.startsWith("media")) {
|
||||
callback(SplitMediaAssetType.ORIGINAL, zipInputStream)
|
||||
}
|
||||
entry = zipInputStream.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
@ -28,7 +28,6 @@ import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver
|
||||
import me.rhunk.snapenhance.core.util.ktx.getObjectField
|
||||
import me.rhunk.snapenhance.core.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie
|
||||
import me.rhunk.snapenhance.core.util.snap.EncryptionHelper
|
||||
import me.rhunk.snapenhance.core.util.snap.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.core.util.snap.PreviewUtils
|
||||
import me.rhunk.snapenhance.data.FileType
|
||||
@ -47,6 +46,7 @@ import me.rhunk.snapenhance.hook.HookAdapter
|
||||
import me.rhunk.snapenhance.hook.HookStage
|
||||
import me.rhunk.snapenhance.hook.Hooker
|
||||
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.nio.file.Paths
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@ -526,42 +526,24 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
val friendInfo: FriendInfo = context.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found in database")
|
||||
val authorName = friendInfo.usernameForSorting!!
|
||||
|
||||
var messageContent = message.messageContent!!
|
||||
var customMediaReferences = mutableListOf<String>()
|
||||
|
||||
if (messageLogger.isMessageRemoved(message.clientConversationId!!, message.serverMessageId.toLong())) {
|
||||
val decodedAttachments = if (messageLogger.isMessageRemoved(message.clientConversationId!!, message.serverMessageId.toLong())) {
|
||||
val messageObject = messageLogger.getMessageObject(message.clientConversationId!!, message.serverMessageId.toLong()) ?: throw Exception("Message not found in database")
|
||||
val messageContentObject = messageObject.getAsJsonObject("mMessageContent")
|
||||
|
||||
messageContent = messageContentObject
|
||||
.getAsJsonArray("mContent")
|
||||
.map { it.asByte }
|
||||
.toByteArray()
|
||||
|
||||
customMediaReferences = messageContentObject
|
||||
.getAsJsonArray("mRemoteMediaReferences")
|
||||
.map { it.asJsonObject.getAsJsonArray("mMediaReferences") }
|
||||
.flatten().map { reference ->
|
||||
Base64.UrlSafe.encode(
|
||||
reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray()
|
||||
)
|
||||
}
|
||||
.toMutableList()
|
||||
MessageDecoder.decode(messageObject.getAsJsonObject("mMessageContent"))
|
||||
} else {
|
||||
MessageDecoder.decode(
|
||||
protoReader = ProtoReader(message.messageContent!!)
|
||||
)
|
||||
}
|
||||
|
||||
val messageReader = ProtoReader(messageContent)
|
||||
val decodedAttachments = MessageDecoder.decode(
|
||||
protoReader = messageReader,
|
||||
customMediaReferences = customMediaReferences.takeIf { it.isNotEmpty() }
|
||||
)
|
||||
|
||||
if (decodedAttachments.isEmpty()) {
|
||||
context.shortToast(translations["no_attachments_toast"])
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPreview) {
|
||||
if (decodedAttachments.size == 1) {
|
||||
if (decodedAttachments.size == 1 ||
|
||||
context.mainActivity == null // we can't show alert dialogs when it downloads from a notification, so it downloads the first one
|
||||
) {
|
||||
downloadMessageAttachments(friendInfo, message, authorName,
|
||||
listOf(decodedAttachments.first())
|
||||
)
|
||||
@ -600,11 +582,15 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
val firstAttachment = decodedAttachments.first()
|
||||
|
||||
val previewCoroutine = async {
|
||||
val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(Base64.decode(firstAttachment.mediaKey!!)) {
|
||||
EncryptionHelper.decryptInputStream(
|
||||
it,
|
||||
decodedAttachments.first().attachmentInfo?.encryption
|
||||
)
|
||||
val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(Base64.decode(firstAttachment.mediaKey!!), decryptionCallback = {
|
||||
firstAttachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it
|
||||
}) ?: return@async null
|
||||
|
||||
val downloadedMediaList = mutableMapOf<SplitMediaAssetType, ByteArray>()
|
||||
|
||||
MediaDownloaderHelper.getSplitElements(ByteArrayInputStream(downloadedMedia)) {
|
||||
type, inputStream ->
|
||||
downloadedMediaList[type] = inputStream.readBytes()
|
||||
}
|
||||
|
||||
val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@async null
|
||||
|
@ -1,7 +1,11 @@
|
||||
package me.rhunk.snapenhance.features.impl.downloader.decoder
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonObject
|
||||
import me.rhunk.snapenhance.core.download.data.toKeyPair
|
||||
import me.rhunk.snapenhance.core.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.data.wrapper.impl.MessageContent
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ -13,6 +17,8 @@ data class DecodedAttachment(
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
object MessageDecoder {
|
||||
private val gson = GsonBuilder().create()
|
||||
|
||||
private fun decodeAttachment(protoReader: ProtoReader): AttachmentInfo? {
|
||||
val mediaInfo = protoReader.followPath(1, 1) ?: return null
|
||||
|
||||
@ -39,6 +45,43 @@ object MessageDecoder {
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun getEncodedMediaReferences(messageContent: JsonElement): List<String> {
|
||||
return getMediaReferences(messageContent).map { reference ->
|
||||
Base64.UrlSafe.encode(
|
||||
reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray()
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun getMediaReferences(messageContent: JsonElement): List<JsonElement> {
|
||||
return messageContent.asJsonObject.getAsJsonArray("mRemoteMediaReferences")
|
||||
.asSequence()
|
||||
.map { it.asJsonObject.getAsJsonArray("mMediaReferences") }
|
||||
.flatten()
|
||||
.sortedBy {
|
||||
it.asJsonObject["mMediaListId"].asLong
|
||||
}.toList()
|
||||
}
|
||||
|
||||
|
||||
fun decode(messageContent: MessageContent): List<DecodedAttachment> {
|
||||
return decode(
|
||||
ProtoReader(messageContent.content),
|
||||
customMediaReferences = getEncodedMediaReferences(gson.toJsonTree(messageContent.instanceNonNull()))
|
||||
)
|
||||
}
|
||||
|
||||
fun decode(messageContent: JsonObject): List<DecodedAttachment> {
|
||||
return decode(
|
||||
ProtoReader(messageContent.getAsJsonArray("mContent")
|
||||
.map { it.asByte }
|
||||
.toByteArray()),
|
||||
customMediaReferences = getEncodedMediaReferences(messageContent)
|
||||
)
|
||||
}
|
||||
|
||||
fun decode(
|
||||
protoReader: ProtoReader,
|
||||
customMediaReferences: List<String>? = null // when customReferences is null it means that the message is from arroyo database
|
||||
@ -138,7 +181,6 @@ object MessageDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return decodedAttachment
|
||||
}
|
||||
}
|
@ -16,9 +16,9 @@ import me.rhunk.snapenhance.core.Logger
|
||||
import me.rhunk.snapenhance.core.download.data.SplitMediaAssetType
|
||||
import me.rhunk.snapenhance.core.eventbus.events.impl.SnapWidgetBroadcastReceiveEvent
|
||||
import me.rhunk.snapenhance.core.util.CallbackBuilder
|
||||
import me.rhunk.snapenhance.core.util.download.RemoteMediaResolver
|
||||
import me.rhunk.snapenhance.core.util.ktx.setObjectField
|
||||
import me.rhunk.snapenhance.core.util.protobuf.ProtoReader
|
||||
import me.rhunk.snapenhance.core.util.snap.EncryptionHelper
|
||||
import me.rhunk.snapenhance.core.util.snap.MediaDownloaderHelper
|
||||
import me.rhunk.snapenhance.core.util.snap.PreviewUtils
|
||||
import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper
|
||||
@ -34,7 +34,6 @@ import me.rhunk.snapenhance.features.impl.downloader.decoder.MessageDecoder
|
||||
import me.rhunk.snapenhance.hook.HookStage
|
||||
import me.rhunk.snapenhance.hook.Hooker
|
||||
import me.rhunk.snapenhance.hook.hook
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
|
||||
@ -246,29 +245,31 @@ class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.IN
|
||||
appendNotifications()
|
||||
}
|
||||
ContentType.SNAP, ContentType.EXTERNAL_MEDIA -> {
|
||||
val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull()).asJsonObject
|
||||
val mediaReferences = serializedMessageContent
|
||||
.getAsJsonArray("mRemoteMediaReferences")
|
||||
.map { it.asJsonObject.getAsJsonArray("mMediaReferences") }
|
||||
.flatten()
|
||||
val mediaReferences = MessageDecoder.getMediaReferences(
|
||||
messageContent = context.gson.toJsonTree(snapMessage.messageContent.instanceNonNull())
|
||||
)
|
||||
|
||||
val mediaReferenceUrls = mediaReferences.map { reference ->
|
||||
val mediaReferenceKeys = mediaReferences.map { reference ->
|
||||
reference.asJsonObject.getAsJsonArray("mContentObject").map { it.asByte }.toByteArray()
|
||||
}
|
||||
|
||||
MessageDecoder.decode(
|
||||
ProtoReader(contentData),
|
||||
customMediaReferences = mediaReferenceUrls.map { Base64.UrlSafe.encode(it) }
|
||||
).forEachIndexed { index, media ->
|
||||
val mediaType = MediaReferenceType.valueOf(mediaReferences[index].asJsonObject["mMediaType"].asString)
|
||||
MessageDecoder.decode(snapMessage.messageContent).firstOrNull()?.also { media ->
|
||||
val mediaType = MediaReferenceType.valueOf(mediaReferences.first().asJsonObject["mMediaType"].asString)
|
||||
|
||||
runCatching {
|
||||
val downloadedMediaList = MediaDownloaderHelper.downloadMediaFromReference(mediaReferenceUrls[index]) { inputStream ->
|
||||
media.attachmentInfo?.encryption?.let { EncryptionHelper.decryptInputStream(inputStream, it) } ?: inputStream
|
||||
val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(mediaReferenceKeys.first(), decryptionCallback = {
|
||||
media.attachmentInfo?.encryption?.decryptInputStream(it) ?: it
|
||||
}) ?: throw Throwable("Unable to download media")
|
||||
|
||||
val downloadedMedias = mutableMapOf<SplitMediaAssetType, ByteArray>()
|
||||
|
||||
MediaDownloaderHelper.getSplitElements(downloadedMedia.inputStream()) { type, inputStream ->
|
||||
downloadedMedias[type] = inputStream.readBytes()
|
||||
}
|
||||
|
||||
var bitmapPreview = PreviewUtils.createPreview(downloadedMediaList[SplitMediaAssetType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!!
|
||||
var bitmapPreview = PreviewUtils.createPreview(downloadedMedias[SplitMediaAssetType.ORIGINAL]!!, mediaType.name.contains("VIDEO"))!!
|
||||
|
||||
downloadedMediaList[SplitMediaAssetType.OVERLAY]?.let {
|
||||
downloadedMedias[SplitMediaAssetType.OVERLAY]?.let {
|
||||
bitmapPreview = PreviewUtils.mergeBitmapOverlay(bitmapPreview, BitmapFactory.decodeByteArray(it, 0, it.size))
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user