commit fad13ee7a26acb8dad7b892927afe9c33f439d16
parent 75fb4755e2b043bb81ed87cab73b943e942d32df
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat, 11 May 2024 13:19:19 +0200

feat: gif, stickers, replies support

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt | 13+++++--------
Mcommon/src/main/assets/lang/en_US.json | 1+
Mcore/src/main/assets/web/export_template.html | 43+++++++++++++++++++++++++------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt | 8++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt | 51+++++++++++++++++++++++----------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentType.kt | 1+
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt | 6+++---
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt | 81++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt | 17+++++++++++------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt | 31++++++++++++++++++++++++++++---
12 files changed, 291 insertions(+), 145 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/pages/LoggerHistoryRoot.kt @@ -29,7 +29,10 @@ import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.common.bridge.wrapper.LoggedMessage import me.rhunk.snapenhance.common.bridge.wrapper.LoggerWrapper import me.rhunk.snapenhance.common.data.ContentType -import me.rhunk.snapenhance.common.data.download.* +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.DownloadRequest +import me.rhunk.snapenhance.common.data.download.MediaDownloadSource +import me.rhunk.snapenhance.common.data.download.createNewFilePath import me.rhunk.snapenhance.common.util.ktx.copyToClipboard import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.protobuf.ProtoReader @@ -79,13 +82,7 @@ class LoggerHistoryRoot : Routes.Route() { } ).enqueue( DownloadRequest( - inputMedias = arrayOf( - InputMedia( - content = attachment.mediaUrlKey!!, - type = DownloadMediaType.PROTO_MEDIA, - encryption = attachment.attachmentInfo?.encryption, - ) - ) + inputMedias = arrayOf(attachment.createInputMedia()!!) ), DownloadMetadata( mediaIdentifier = attachmentHash, diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -1415,6 +1415,7 @@ "attachment_type": { "snap": "Snap", "sticker": "Sticker", + "gif": "GIF", "external_media": "External Media", "note": "Note", "original_story": "Original Story" diff --git a/core/src/main/assets/web/export_template.html b/core/src/main/assets/web/export_template.html @@ -286,28 +286,34 @@ messageObject.appendChild(((messageContainer) => { messageContainer.classList.add("content") - messageContainer.innerHTML = message.serializedContent const observers = [] - if (!message.serializedContent) { - messageContainer.innerHTML = "" - let messageData = "" - switch (message.type) { - case "SNAP": - messageContainer.appendChild(document.querySelector('.red_snap_svg').cloneNode(true)) - messageData = "Snap" - break - default: - messageData = message.type - } - messageContainer.innerHTML += messageData - messageContainer.onclick = () => { - observers.forEach(f => f()) + + function loadContent() { + if (!message.serializedContent) { + let messageData = "" + switch (message.type) { + case "SNAP": + messageContainer.appendChild(document.querySelector('.red_snap_svg').cloneNode(true)) + messageData += "Snap" + break + default: + messageData += message.type + } + messageContainer.innerHTML = messageData + messageContainer.onclick = () => { + messageContainer.prepend(document.createElement("br")) + observers.forEach(f => f()) + } + } else { + messageContainer.innerHTML = message.serializedContent } } + loadContent() + if (message.attachments && message.attachments.length > 0) { - message.attachments.forEach((attachment, index) => { + message.attachments.reverse().forEach((attachment, index) => { const mediaKey = attachment.key.replace(/(=)/g, "") observers.push(() => { @@ -316,12 +322,11 @@ if (!originalMedia) { return } - messageContainer.innerHTML = "" const originalMediaUrl = decodeMedia(originalMedia) const mediaContainer = document.createElement("div") - messageContainer.appendChild(mediaContainer) + messageContainer.prepend(mediaContainer) const imageTag = document.createElement("img") imageTag.src = originalMediaUrl @@ -356,6 +361,8 @@ new IntersectionObserver(entries => { if (!fetched && entries[0].isIntersecting === true) { fetched = true + loadContent() + messageContainer.prepend(document.createElement("br")) observers.forEach(c => { try { c() diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/DownloadManagerClient.kt @@ -68,6 +68,14 @@ class DownloadManagerClient ( ) } + fun downloadInputMedias(inputMedias: Array<InputMedia>) { + enqueueDownloadRequest( + DownloadRequest( + inputMedias = inputMedias + ) + ) + } + fun downloadStream( streamUrl: String, audioStreamFormat: AudioStreamFormat diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -52,11 +52,9 @@ import me.rhunk.snapenhance.core.wrapper.impl.media.opera.Layer import me.rhunk.snapenhance.core.wrapper.impl.media.opera.ParamMap import me.rhunk.snapenhance.core.wrapper.impl.media.toKeyPair import me.rhunk.snapenhance.mapper.impl.OperaPageViewControllerMapper -import java.io.ByteArrayInputStream import java.nio.file.Paths import java.util.UUID import kotlin.coroutines.suspendCoroutine -import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.absoluteValue @@ -544,7 +542,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp attachments: List<DecodedAttachment>, forceAllowDuplicate: Boolean = false ) { - //TODO: stickers attachments.forEach { attachment -> runCatching { provideDownloadManagerClient( @@ -554,12 +551,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp friendInfo = friendInfo, forceAllowDuplicate = forceAllowDuplicate, creationTimestamp = message.creationTimestamp, - ).downloadSingleMedia( - mediaData = attachment.mediaUrlKey!!, - mediaType = DownloadMediaType.PROTO_MEDIA, - encryption = attachment.attachmentInfo?.encryption, - attachmentType = attachment.type - ) + ).apply { + downloadInputMedias( + arrayOf(attachment.createInputMedia()!!) + ) + } }.onFailure { context.longToast(translations["failed_generic_toast"]) context.log.error("Failed to download", it) @@ -577,32 +573,31 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ) { var previewBitmap: Bitmap? = null val previewCoroutine = context.coroutineScope.launch { - val downloadedMedia = RemoteMediaResolver.downloadBoltMedia(Base64.decode(attachment.mediaUrlKey!!), decryptionCallback = { - attachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it - }) ?: return@launch + runCatching { + attachment.openStream { attachmentStream -> + val downloadedMediaList = mutableMapOf<SplitMediaAssetType, ByteArray>() - val downloadedMediaList = mutableMapOf<SplitMediaAssetType, ByteArray>() + MediaDownloaderHelper.getSplitElements(attachmentStream!!) { + type, inputStream -> + downloadedMediaList[type] = inputStream.readBytes() + } - MediaDownloaderHelper.getSplitElements(ByteArrayInputStream(downloadedMedia)) { - type, inputStream -> - downloadedMediaList[type] = inputStream.readBytes() - } + val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@openStream + val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] - val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@launch - val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] + var bitmap = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) + ?: throw Exception("preview is null") - var bitmap: Bitmap? = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) + overlay?.also { + bitmap = PreviewUtils.mergeBitmapOverlay(bitmap, BitmapFactory.decodeByteArray(it, 0, it.size)) + } - if (bitmap == null) { + previewBitmap = bitmap + } + }.onFailure { context.shortToast(translations["failed_to_create_preview_toast"]) - return@launch + context.log.error("Failed to create preview", it) } - - overlay?.also { - bitmap = PreviewUtils.mergeBitmapOverlay(bitmap!!, BitmapFactory.decodeByteArray(it, 0, it.size)) - } - - previewBitmap = bitmap } with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentType.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/AttachmentType.kt @@ -5,6 +5,7 @@ enum class AttachmentType( ) { SNAP("snap"), STICKER("sticker"), + GIF("gif"), EXTERNAL_MEDIA("external_media"), NOTE("note"), ORIGINAL_STORY("original_story"), diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/decoder/MessageDecoder.kt @@ -3,20 +3,58 @@ package me.rhunk.snapenhance.core.features.impl.downloader.decoder import com.google.gson.GsonBuilder import com.google.gson.JsonElement import com.google.gson.JsonObject +import me.rhunk.snapenhance.common.data.download.DownloadMediaType +import me.rhunk.snapenhance.common.data.download.InputMedia +import me.rhunk.snapenhance.common.data.download.MediaEncryptionKeyPair import me.rhunk.snapenhance.common.data.download.toKeyPair import me.rhunk.snapenhance.common.util.protobuf.ProtoReader +import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver import me.rhunk.snapenhance.core.wrapper.impl.MessageContent +import java.io.InputStream +import java.net.URL import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi data class DecodedAttachment( - val mediaUrlKey: String?, + val boltKey: String?, + val directUrl: String? = null, val type: AttachmentType, val attachmentInfo: AttachmentInfo? ) { @OptIn(ExperimentalEncodingApi::class) val mediaUniqueId: String? by lazy { - runCatching { Base64.UrlSafe.decode(mediaUrlKey.toString()) }.getOrNull()?.let { ProtoReader(it).getString(2, 2)?.substringBefore(".") } + runCatching { + Base64.UrlSafe.decode(boltKey.toString()) + }.getOrNull()?.let { + ProtoReader(it).getString(2, 2)?.substringBefore(".") + } ?: directUrl?.substringAfterLast("/")?.substringBeforeLast("?")?.substringBeforeLast(".")?.let { Base64.UrlSafe.encode(it.toByteArray()) } + } + + @OptIn(ExperimentalEncodingApi::class) + inline fun openStream(crossinline callback: (InputStream?) -> Unit) { + boltKey?.let { mediaUrlKey -> + RemoteMediaResolver.downloadBoltMedia(Base64.decode(mediaUrlKey), decryptionCallback = { + attachmentInfo?.encryption?.decryptInputStream(it) ?: it + }, resultCallback = { inputStream, _ -> + callback(inputStream) + }) + } ?: directUrl?.let { rawMediaUrl -> + URL(rawMediaUrl).openStream().let { inputStream -> + attachmentInfo?.encryption?.decryptInputStream(inputStream) ?: inputStream + }.use(callback) + } ?: callback(null) + } + + fun createInputMedia( + isOverlay: Boolean = false + ): InputMedia? { + return InputMedia( + content = boltKey ?: directUrl ?: return null, + type = if (boltKey != null) DownloadMediaType.PROTO_MEDIA else DownloadMediaType.REMOTE_MEDIA, + encryption = attachmentInfo?.encryption, + attachmentType = type.key, + isOverlay = isOverlay + ) } } @@ -24,32 +62,41 @@ data class DecodedAttachment( object MessageDecoder { private val gson = GsonBuilder().create() - private fun decodeAttachment(protoReader: ProtoReader): AttachmentInfo? { - val mediaInfo = protoReader.followPath(1, 1) ?: return null + private fun ProtoReader.decodeClearTextEncryption(encoded: Boolean = true): MediaEncryptionKeyPair? { + val key = if (encoded) Base64.decode(getString(1)?.trim() ?: return null) else getByteArray(1) ?: return null + val iv = if (encoded) Base64.decode(getString(2)?.trim() ?: return null) else getByteArray(2) ?: return null + + return Pair(key, iv).toKeyPair() + } + private fun ProtoReader.decodeMediaMetadata(): AttachmentInfo { return AttachmentInfo( encryption = run { - val encryptionProtoIndex = if (mediaInfo.contains(19)) 19 else 4 - val encryptionProto = mediaInfo.followPath(encryptionProtoIndex) ?: return@run null - - var key = encryptionProto.getByteArray(1) ?: return@run null - var iv = encryptionProto.getByteArray(2) ?: return@run null - - if (encryptionProtoIndex == 4) { - key = Base64.decode(encryptionProto.getString(1)?.replace("\n","") ?: return@run null) - iv = Base64.decode(encryptionProto.getString(2)?.replace("\n","") ?: return@run null) + followPath(4)?.apply { + decodeClearTextEncryption(encoded = true)?.let { + return@run it + } } - Pair(key, iv).toKeyPair() + followPath(19)?.apply { + decodeClearTextEncryption( encoded = false)?.let { encryption -> + return@run encryption + } + } + null }, - resolution = mediaInfo.followPath(5)?.let { + resolution = followPath(5)?.let { (it.getVarInt(1)?.toInt() ?: 0) to (it.getVarInt(2)?.toInt() ?: 0) }, - duration = mediaInfo.getVarInt(15) // external medias - ?: mediaInfo.getVarInt(13) // audio notes + duration = getVarInt(15) // external medias + ?: getVarInt(13) // audio notes ) } + private fun ProtoReader.decodeAttachment(): AttachmentInfo? { + return followPath(1, 1)?.decodeMediaMetadata() + } + @OptIn(ExperimentalEncodingApi::class) fun getEncodedMediaReferences(messageContent: JsonElement): List<String> { return getMediaReferences(messageContent).map { reference -> @@ -80,7 +127,7 @@ object MessageDecoder { ProtoReader(messageContent.content!!), customMediaReferences = getEncodedMediaReferences(gson.toJsonTree(messageContent.instanceNonNull())) ).toMutableList().apply { - if (messageContent.quotedMessage != null && messageContent.quotedMessage!!.content != null) { + if (messageContent.quotedMessage?.takeIf { it.isPresent() } != null && messageContent.quotedMessage!!.content?.takeIf { it.isPresent() } != null) { addAll(0, decode( MessageContent(messageContent.quotedMessage!!.content!!.instanceNonNull()) )) @@ -110,43 +157,64 @@ object MessageDecoder { customMediaReferences?.let { mediaReferences.addAll(it) } var mediaKeyIndex = 0 - fun decodeSnapDocMediaPlayback(type: AttachmentType, protoReader: ProtoReader) { + fun ProtoReader.decodeSnapDocMediaPlayback(type: AttachmentType) { decodedAttachment.add( DecodedAttachment( - mediaUrlKey = mediaReferences.getOrNull(mediaKeyIndex++), + boltKey = mediaReferences.getOrNull(mediaKeyIndex++), type = type, - attachmentInfo = decodeAttachment(protoReader) ?: return + attachmentInfo = decodeAttachment() ?: return ) ) } - fun decodeSnapDocMedia(type: AttachmentType, protoReader: ProtoReader) { - protoReader.followPath(5) { decodeSnapDocMediaPlayback(type,this) } + fun ProtoReader.decodeSnapDocMedia(type: AttachmentType) { + followPath(5) { decodeSnapDocMediaPlayback(type) } } - fun decodeSticker(protoReader: ProtoReader) { - protoReader.followPath(1) { + fun ProtoReader.decodeStickers() { + followPath(1) { + val packId = getString(1) + val reference = getString(2) ?: return@followPath + val stickerUrl = when (packId) { + "snap" -> "https://gcs.sc-cdn.net/sticker-packs-sc/stickers/$reference" + "bitmoji" -> reference.split(":").let { + "https://cf-st.sc-cdn.net/3d/render/${ + it.getOrNull(0) ?: return@followPath + }-${it.drop(2).joinToString("-")}-v${it.getOrNull(1) ?: return@followPath}.webp?ua=2" + } + else -> return@followPath + } decodedAttachment.add( DecodedAttachment( - mediaUrlKey = null, + boltKey = null, + directUrl = stickerUrl, type = AttachmentType.STICKER, attachmentInfo = BitmojiSticker( - reference = getString(2) ?: return@followPath + reference = reference ) ) ) } + followPath(2, 1) { + decodedAttachment.add( + DecodedAttachment( + boltKey = mediaReferences.getOrNull(mediaKeyIndex++), + type = AttachmentType.STICKER, + attachmentInfo = decodeMediaMetadata() + ) + ) + } } fun ProtoReader.decodeShares() { // saved story followPath(24, 2) { - decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA, this) + decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) } // memories story followPath(11) { eachBuffer(3) { - decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA, this) + decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) } } } @@ -163,11 +231,11 @@ object MessageDecoder { mediaReader.apply { // external media eachBuffer(3, 3) { - decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA, this) + decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) } // stickers - followPath(4) { decodeSticker(this) } + followPath(4) { decodeStickers() } // shares followPath(5) { @@ -176,11 +244,11 @@ object MessageDecoder { // audio notes followPath(6) note@{ - val audioNote = decodeAttachment(this) ?: return@note + val audioNote = decodeAttachment() ?: return@note decodedAttachment.add( DecodedAttachment( - mediaUrlKey = mediaReferences.getOrNull(mediaKeyIndex++), + boltKey = mediaReferences.getOrNull(mediaKeyIndex++), type = AttachmentType.NOTE, attachmentInfo = audioNote ) @@ -191,37 +259,71 @@ object MessageDecoder { followPath(7) { // original story reply followPath(3) { - decodeSnapDocMedia(AttachmentType.ORIGINAL_STORY, this) + decodeSnapDocMedia(AttachmentType.ORIGINAL_STORY) } // external medias followPath(12) { - eachBuffer(3) { decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA, this) } + eachBuffer(3) { decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) } } // attached sticker - followPath(13) { decodeSticker(this) } + followPath(13) { decodeStickers() } // reply shares followPath(14) { decodeShares() } // attached audio note - followPath(15) { decodeSnapDocMediaPlayback(AttachmentType.NOTE, this) } + followPath(15) { decodeSnapDocMediaPlayback(AttachmentType.NOTE) } // reply snap followPath(17) { - decodeSnapDocMedia(AttachmentType.SNAP, this) + decodeSnapDocMedia(AttachmentType.SNAP) } } // snaps followPath(11) { - decodeSnapDocMedia(AttachmentType.SNAP, this) + decodeSnapDocMedia(AttachmentType.SNAP) + } + + // creative tools items + followPath(14, 2, 2) { + // custom sticker + followPath(3) sticker@{ + decodedAttachment.add( + DecodedAttachment( + boltKey = if (contains(4)) { + Base64.UrlSafe.encode(getByteArray(4, 4) ?: return@sticker) + } else mediaReferences.getOrNull(mediaKeyIndex++), + type = AttachmentType.STICKER, + attachmentInfo = AttachmentInfo( + encryption = decodeClearTextEncryption(encoded = true) ?: followPath(5) + ?.decodeClearTextEncryption(encoded = false) + ) + ) + ) + } + + // gifs + followPath(13) { + eachBuffer(4) { + followPath(2) { + decodedAttachment.add( + DecodedAttachment( + boltKey = getByteArray(4)?.let { Base64.UrlSafe.encode(it) }, + type = AttachmentType.GIF, + attachmentInfo = null + ) + ) + } + } + } } // map reaction followPath(20, 2) { - decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA, this) + decodeSnapDocMedia(AttachmentType.EXTERNAL_MEDIA) } } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -25,6 +25,7 @@ import me.rhunk.snapenhance.core.util.EvictingMap import me.rhunk.snapenhance.core.util.ktx.getDimens import me.rhunk.snapenhance.core.util.ktx.getId import me.rhunk.snapenhance.core.util.ktx.getIdentifier +import me.rhunk.snapenhance.core.wrapper.impl.getMessageText import java.util.WeakHashMap import kotlin.math.absoluteValue @@ -55,9 +56,8 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview", loadParams } ?: return@mapNotNull null - val messageString = messageContainer.getString(2, 1) - ?: ContentType.fromMessageContainer(messageContainer)?.name - ?: ContentType.fromId(message.contentType) + val contentType = ContentType.fromMessageContainer(messageContainer) ?: ContentType.fromId(message.contentType) + val messageString = messageContainer.getBuffer().getMessageText(contentType) ?: contentType.name val friendName = friendNameCache.getOrPut(message.senderId ?: return@mapNotNull null) { context.database.getFriendInfo(message.senderId ?: return@mapNotNull null)?.let { it.displayName?: it.mutableUsername } ?: "Unknown" diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/messaging/ConversationExporter.kt @@ -9,9 +9,7 @@ import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.database.impl.FriendFeedEntry import me.rhunk.snapenhance.common.database.impl.FriendInfo import me.rhunk.snapenhance.common.util.snap.MediaDownloaderHelper -import me.rhunk.snapenhance.common.util.snap.RemoteMediaResolver import me.rhunk.snapenhance.core.ModContext -import me.rhunk.snapenhance.core.features.impl.downloader.decoder.AttachmentType import me.rhunk.snapenhance.core.features.impl.downloader.decoder.MessageDecoder import me.rhunk.snapenhance.core.wrapper.impl.Message import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID @@ -21,6 +19,7 @@ import java.io.InputStream import java.io.OutputStream import java.text.DateFormat import java.util.Date +import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.Executors import java.util.zip.Deflater import java.util.zip.DeflaterInputStream @@ -104,54 +103,55 @@ class ConversationExporter( } } + private val downloadedMediaIdCache = CopyOnWriteArraySet<String>() + private val pendingDownloadMediaIdCache = CopyOnWriteArraySet<String>() - @OptIn(ExperimentalEncodingApi::class) private fun downloadMedia(message: Message) { downloadThreadExecutor.execute { MessageDecoder.decode(message.messageContent!!).forEach decode@{ attachment -> - if (attachment.mediaUrlKey?.isEmpty() == true) return@decode - val protoMediaReference = Base64.UrlSafe.decode(attachment.mediaUrlKey ?: return@decode) - + if (attachment.mediaUniqueId in downloadedMediaIdCache || attachment.mediaUniqueId in pendingDownloadMediaIdCache) return@decode + pendingDownloadMediaIdCache.add(attachment.mediaUniqueId!!) for (i in 0..5) { - printLog("downloading ${attachment.mediaUrlKey}... (attempt ${i + 1}/5)") + printLog("downloading ${attachment.boltKey ?: attachment.directUrl}... (attempt ${i + 1}/5)") runCatching { - RemoteMediaResolver.downloadBoltMedia(protoMediaReference, decryptionCallback = { - (attachment.attachmentInfo?.encryption?.decryptInputStream(it) ?: it) - }) { downloadedInputStream, _ -> - downloadedInputStream.use { inputStream -> - MediaDownloaderHelper.getSplitElements(inputStream) { type, splitInputStream -> - val mediaKey = "${type}_${Base64.UrlSafe.encode(protoMediaReference).replace("=", "")}" - val bufferedInputStream = BufferedInputStream(splitInputStream) - val fileType = MediaDownloaderHelper.getFileType(bufferedInputStream) - val mediaFile = cacheFolder.resolve("$mediaKey.${fileType.fileExtension}") - - mediaFile.outputStream().use { fos -> - bufferedInputStream.copyTo(fos) - } + attachment.openStream { downloadedInputStream -> + MediaDownloaderHelper.getSplitElements(downloadedInputStream!!) { type, splitInputStream -> + val mediaKey = "${type}_${attachment.mediaUniqueId}" + val bufferedInputStream = BufferedInputStream(splitInputStream) + val fileType = MediaDownloaderHelper.getFileType(bufferedInputStream) + val mediaFile = cacheFolder.resolve("$mediaKey.${fileType.fileExtension}") + + mediaFile.outputStream().use { fos -> + bufferedInputStream.copyTo(fos) + } - writeThreadExecutor.execute { - outputFileStream.write("<div class=\"media-$mediaKey\"><!-- ".toByteArray()) - mediaFile.inputStream().use { - val deflateInputStream = DeflaterInputStream(it, Deflater(Deflater.BEST_SPEED, true)) - (XposedHelpers.newInstance( - Base64InputStream::class.java, - deflateInputStream, - android.util.Base64.DEFAULT or android.util.Base64.NO_WRAP, - true - ) as InputStream).copyTo(outputFileStream) - outputFileStream.write(" --></div>\n".toByteArray()) - outputFileStream.flush() - } + writeThreadExecutor.execute { + outputFileStream.write("<div class=\"media-$mediaKey\"><!-- ".toByteArray()) + mediaFile.inputStream().use { + val deflateInputStream = DeflaterInputStream(it, Deflater(Deflater.BEST_SPEED, true)) + (XposedHelpers.newInstance( + Base64InputStream::class.java, + deflateInputStream, + android.util.Base64.DEFAULT or android.util.Base64.NO_WRAP, + true + ) as InputStream).copyTo(outputFileStream) + outputFileStream.write(" --></div>\n".toByteArray()) + outputFileStream.flush() } } } + writeThreadExecutor.execute { + downloadedMediaIdCache.add(attachment.mediaUniqueId!!) + } } return@decode }.onFailure { - printLog("failed to download media ${attachment.mediaUrlKey}. retrying...") + downloadedMediaIdCache.remove(attachment.mediaUniqueId!!) + printLog("failed to download media ${attachment.boltKey}. retrying...") it.printStackTrace() } } + pendingDownloadMediaIdCache.remove(attachment.mediaUniqueId!!) } } } @@ -168,7 +168,13 @@ class ConversationExporter( } val contentType = message.messageContent?.contentType ?: return - if (exportParams.downloadMedias && (contentType == ContentType.NOTE || contentType == ContentType.SNAP || contentType == ContentType.EXTERNAL_MEDIA)) { + if (exportParams.downloadMedias && (contentType == ContentType.NOTE || + contentType == ContentType.SNAP || + contentType == ContentType.EXTERNAL_MEDIA || + contentType == ContentType.STICKER || + contentType == ContentType.SHARE || + contentType == ContentType.MAP_REACTION) + ) { downloadMedia(message) } @@ -201,10 +207,9 @@ class ConversationExporter( name("attachments").beginArray() MessageDecoder.decode(message.messageContent!!) .forEach attachments@{ attachments -> - if (attachments.type == AttachmentType.STICKER) //TODO: implement stickers - return@attachments beginObject() - name("key").value(attachments.mediaUrlKey?.replace("=", "")) + name("url").value(attachments.boltKey ?: attachments.directUrl) + name("key").value(attachments.mediaUniqueId) name("type").value(attachments.type.toString()) name("encryption").apply { attachments.attachmentInfo?.encryption?.let { encryption -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/ui/menu/impl/NewChatActionMenu.kt @@ -193,7 +193,7 @@ class NewChatActionMenu : AbstractMenu() { decodedAttachments.mapIndexed { index, attachment -> StringBuilder().apply { append("---- media $index ----\n") - append("resolveProto: ${attachment.mediaUrlKey}\n") + append("resolveProto: ${attachment.boltKey}\n") append("type: ${attachment.type}\n") attachment.attachmentInfo?.apply { encryption?.let { @@ -207,11 +207,16 @@ class NewChatActionMenu : AbstractMenu() { } } runCatching { - val mediaHeaders = RemoteMediaResolver.getMediaHeaders( - Base64.UrlSafe.decode(attachment.mediaUrlKey ?: return@runCatching)) - append("content-type: ${mediaHeaders["content-type"]}\n") - append("content-length: ${Formatter.formatShortFileSize(context, mediaHeaders["content-length"]?.toLongOrNull() ?: 0)}\n") - append("creation-date: ${mediaHeaders["last-modified"]}\n") + attachment.boltKey?.let { + val mediaHeaders = RemoteMediaResolver.getMediaHeaders( + Base64.UrlSafe.decode(it)) + append("content-type: ${mediaHeaders["content-type"]}\n") + append("content-length: ${Formatter.formatShortFileSize(context, mediaHeaders["content-length"]?.toLongOrNull() ?: 0)}\n") + append("creation-date: ${mediaHeaders["last-modified"]}\n") + } + attachment.directUrl?.let { + append("url: $it\n") + } } }.toString() }.joinToString("\n\n") diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/AbstractWrapper.kt @@ -19,7 +19,7 @@ abstract class AbstractWrapper( inner class FieldAccessor<T>(private val fieldName: String, private val mapper: ((Any?) -> T?)? = null) { @Suppress("UNCHECKED_CAST") operator fun getValue(obj: Any, property: KProperty<*>): T? { - val value = XposedHelpers.getObjectField(instance, fieldName) + val value = runCatching { XposedHelpers.getObjectField(instance, fieldName) }.getOrNull() return if (mapper != null) { mapper.invoke(value) } else { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/wrapper/impl/Message.kt @@ -7,6 +7,31 @@ import me.rhunk.snapenhance.core.wrapper.AbstractWrapper import org.mozilla.javascript.annotations.JSGetter import org.mozilla.javascript.annotations.JSSetter + +fun ByteArray.getMessageText(contentType: ContentType): String? { + val protoReader by lazy { ProtoReader(this) } + return when (contentType) { + ContentType.CHAT -> protoReader.getString(2, 1) ?: "Failed to parse message" + ContentType.TINY_SNAP -> protoReader.getString(19, 1, 1) + ContentType.EXTERNAL_MEDIA -> protoReader.getString(7, 11, 1) + ContentType.SNAP -> protoReader.followPath(11, 5)?.run { + val captions = mutableListOf<String>() + + eachBuffer(1) { + followPath(4) { + val caption = getString(3, 2, 1) + if (caption != null) { + captions.add(caption) + } + } + } + + captions.takeIf { it.isNotEmpty() }?.joinToString("\n") + } + else -> null + } +} + class Message(obj: Any?) : AbstractWrapper(obj) { @get:JSGetter @set:JSSetter var orderKey by field<Long>("mOrderKey") @@ -21,7 +46,7 @@ class Message(obj: Any?) : AbstractWrapper(obj) { @get:JSGetter @set:JSSetter var messageState by enum("mState", MessageState.COMMITTED) - fun serialize() = if (messageContent!!.contentType == ContentType.CHAT) { - ProtoReader(messageContent!!.content!!).getString(2, 1) ?: "Failed to parse message" - } else null + fun serialize(): String?{ + return messageContent?.content?.getMessageText(messageContent?.contentType ?: return null) + } } \ No newline at end of file