diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 5a82c4e9..82a2e99d 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -46,18 +46,19 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.SnapPlaylistItem import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair +import me.eternal.purrfectsnap.core.wrapper.impl.media.EncryptionWrapper import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper import java.nio.file.Paths import java.util.UUID import kotlin.coroutines.suspendCoroutine import kotlin.math.absoluteValue +import android.util.Base64 class SnapChapterInfo( val offset: Long, val duration: Long? ) - class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleType.AUTO_DOWNLOAD) { private var lastSeenMediaInfoMap: MutableMap? = null var lastSeenMapParams: ParamMap? = null @@ -204,49 +205,107 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp } } - private fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map, paramMap: ParamMap) { + private fun extractStoryEncryption(paramMap: ParamMap): MediaEncryptionKeyPair? { + + val keyRaw = paramMap["CONTEXT_REPLY_MEDIA_KEY"] as? String + ?: paramMap["REPLY_MEDIA_KEY"] as? String + ?: return null + + val ivRaw = paramMap["CONTEXT_REPLY_MEDIA_IV"] as? String + ?: paramMap["REPLY_MEDIA_IV"] as? String + ?: return null + + return try { + + val keyBytes = android.util.Base64.decode(keyRaw, android.util.Base64.DEFAULT) + val ivBytes = android.util.Base64.decode(ivRaw, android.util.Base64.DEFAULT) + + if (keyBytes.size != 32 || ivBytes.size != 16) { + return null + } + + MediaEncryptionKeyPair( + key = android.util.Base64.encodeToString(keyBytes, android.util.Base64.NO_WRAP), + iv = android.util.Base64.encodeToString(ivBytes, android.util.Base64.NO_WRAP), + urlSafe = false + ) + + } catch (e: Exception) { + context.log.error("Story AES decode failed", e) + null + } + } + + private fun downloadOperaMedia( + downloadManagerClient: DownloadManagerClient, + mediaInfoMap: Map, + paramMap: ParamMap + ) { if (mediaInfoMap.isEmpty()) return - paramMap["SNAP_ID"]?.toString()?.let { snapId -> - context.database.getStorySnapEntry(snapId)?.let { storySnapEntry -> - downloadManagerClient.downloadSingleMedia( - storySnapEntry.mediaUrl ?: throw Exception("Media URL not found"), - DownloadMediaType.fromUri(Uri.parse(storySnapEntry.mediaUrl)), - (storySnapEntry.mediaKey to storySnapEntry.mediaIv).takeIf { it.first != null && it.second != null }?.let { (key, iv) -> - MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false) - } - ) - return - } - } + val storyKeyPair = extractStoryEncryption(paramMap) val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) + paramMap["SNAP_ID"]?.toString()?.let { snapId -> + context.database.getStorySnapEntry(snapId)?.let { storySnapEntry -> + + val urlToDownload = storySnapEntry?.mediaUrl ?: originalMediaInfo.uri + val encryptionPair = + safeGetEncryptionPair(originalMediaInfo) + ?: extractStoryEncryption(paramMap) + + downloadManagerClient.downloadSingleMedia( + originalMediaInfoReference, + DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), + encryptionPair + ) + + return + } + } + mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay -> val overlayReference = handleLocalReferences(overlay.uri) + val originalEncryption = safeGetEncryptionPair(originalMediaInfo) + val overlayEncryption = overlay.encryption?.toKeyPair() + downloadManagerClient.downloadMediaWithOverlay( original = InputMedia( originalMediaInfoReference, DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - originalMediaInfo.encryption?.toKeyPair() + encryption = originalEncryption ), overlay = InputMedia( overlayReference, DownloadMediaType.fromUri(Uri.parse(overlayReference)), - overlay.encryption?.toKeyPair(), + encryption = overlayEncryption, isOverlay = true ) ) return } + // fallback to single media download + val encryptionPair = + storyKeyPair ?: safeGetEncryptionPair(originalMediaInfo) + downloadManagerClient.downloadSingleMedia( originalMediaInfoReference, DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - originalMediaInfo.encryption?.toKeyPair() + encryptionPair ) + + } + + private fun safeGetEncryptionPair(info: MediaInfo?): MediaEncryptionKeyPair? { + val enc = info?.encryption ?: return null + return runCatching { + enc.toKeyPair() + }.onFailure { context.log.verbose("Failed to parse encryption key pair → ${info.uri}") + }.getOrNull() } fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean { @@ -254,6 +313,32 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp return canUseRule(databaseMessage.clientConversationId!!) } + private fun resolveMediaUrl(raw: String): String { + var url = raw.trim() + + // Remove garbage prefix/suffix sometimes present + if (url.contains("https://")) { + url = url.substringAfter("https://") + url = "https://$url" + } + + val knownGoodPrefixes = listOf( + "https://cf-st.sc-cdn.net", + "https://bolt-gcdn.sc-cdn.net", + "https://app.snapchat.com", + "https://cf-st-nl1.sc-cdn.net" // sometimes region specific + ) + + return when { + url.startsWith("http") -> url + knownGoodPrefixes.any { url.contains(it) } -> url.substringAfterLast("http") + else -> "${RemoteMediaResolver.CF_ST_CDN_D}/$url" + }.also { resolved -> + if (resolved != raw) { + context.log.debug("URL rewritten: $raw → $resolved") + } + } + } /** * Handles the media from the opera viewer * @@ -262,11 +347,13 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp * @param forceDownload if the media should be downloaded */ private fun handleOperaMedia( - paramMap: ParamMap, - mediaInfoMap: Map, - forceDownload: Boolean, + paramMap: ParamMap, + mediaInfoMap: Map, + forceDownload: Boolean, forceAllowDuplicate: Boolean = false ) { + //messages paramMap["MESSAGE_ID"]?.toString()?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { id -> val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() @@ -307,28 +394,40 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp }?.let { playlistGroup -> val playlistGroupString = playlistGroup.toString() - val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let { - if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null - } ?: if (playlistGroupString.contains("storyUserId=")) { - playlistGroupString.substringAfter("storyUserId=").substringBefore(",") - } else { - //story replies - val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" } - ?.invoke(playlistGroup)?.toString() - ?.split(":")?.getOrNull(2) ?: return@let + // Try multiple possible keys/paths in order of likelihood + val storyUserId = sequenceOf( + // Most common new locations + { paramMap["STORY_USER_ID"]?.toString() }, + { paramMap["CREATOR_USER_ID"]?.toString() }, + { paramMap["USER_ID"]?.toString() }, + { paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() }, + // Old ones as fallback + { paramMap["PLAYABLE_STORY_SNAP_RECORD"] + ?.toString() + ?.substringAfter("userId=") + ?.substringBefore(",") }, + // Parse playlistGroup string more carefully + { playlistGroupString.substringAfter("userId=", missingDelimiterValue = "").substringBefore(",") }, + { playlistGroupString.substringAfter("storyUserId=", missingDelimiterValue = "").substringBefore(",") }, + // Last desperate fallback — sometimes it's in nested snap record + { paramMap["SNAP_PLAYLIST_ITEM"] + ?.toString() + ?.substringAfter("userId=") + ?.substringBefore(",") } + ).mapNotNull { it() }.firstOrNull { it.isNotBlank() && it != "null" } - val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let - val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let + // ────────────────────────────────────────────── - conversationParticipants.firstOrNull { it != conversationMessage.senderId } + val authorUserId = storyUserId ?: run { + context.log.warn("[FriendStories] Could not extract user ID — falling back to current user") + context.database.myUserId // prevents crash, but will tag as self } - val author = context.database.getFriendInfo( - if (storyUserId == null || storyUserId == "null") - context.database.myUserId - else storyUserId - ) ?: throw Exception("Friend not found in database") - val authorName = author.usernameForSorting!! + val author = context.database.getFriendInfo(authorUserId) + ?: context.database.getFriendInfoByUsername(authorUserId) // sometimes it's username + ?: throw Exception("No friend info for ID: $authorUserId") + + val authorName = author.usernameForSorting ?: author.displayName ?: "UnknownFriend" if (!forceDownload) { if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return @@ -381,13 +480,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp return "${(hours % 24).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}" } - val playlistUrl = paramMap["MEDIA_ID"].toString().let { - val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net")) - - urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex -> - it.substring(validIndex) - } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" - } + val playlistUrl = resolveMediaUrl(paramMap["MEDIA_ID"].toString()) context.runOnUiThread { val selectedChapters = mutableListOf() diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/EncryptionWrapper.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/EncryptionWrapper.kt index 15a780fd..af79620f 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/EncryptionWrapper.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/EncryptionWrapper.kt @@ -10,10 +10,19 @@ import javax.crypto.CipherInputStream import javax.crypto.CipherOutputStream import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec -import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import android.util.Base64 + +enum class SnapCipherMode { + CBC, + CTR +} + +class EncryptionWrapper( + instance: Any?, + private val mode: SnapCipherMode = SnapCipherMode.CBC +) : AbstractWrapper(instance) { -class EncryptionWrapper(instance: Any?) : AbstractWrapper(instance) { fun decrypt(data: ByteArray?): ByteArray { return newCipher(Cipher.DECRYPT_MODE).doFinal(data) } @@ -26,56 +35,58 @@ class EncryptionWrapper(instance: Any?) : AbstractWrapper(instance) { return CipherOutputStream(outputStream, newCipher(Cipher.DECRYPT_MODE)) } - /** - * Search for a byte[] field with the specified length - * - * @param arrayLength the length of the byte[] field - * @return the field - */ - private fun searchByteArrayField(arrayLength: Int): Field { - return instanceNonNull()::class.java.fields.first { f -> - try { - if (!f.type.isArray || f.type - .componentType != Byte::class.javaPrimitiveType - ) return@first false - return@first (f.get(instanceNonNull()) as ByteArray).size == arrayLength - } catch (e: Exception) { - return@first false - } - } - } - - /** - * Create a new cipher with the specified mode - */ - fun newCipher(mode: Int): Cipher { + fun newCipher(modeInt: Int): Cipher { val cipher = cipher - cipher.init(mode, SecretKeySpec(keySpec, "AES"), IvParameterSpec(ivKeyParameterSpec)) + cipher.init( + modeInt, + SecretKeySpec(keySpec, "AES"), + IvParameterSpec(ivKeyParameterSpec) + ) return cipher } /** - * Get the cipher from the encryption wrapper + * Dynamic cipher selection */ private val cipher: Cipher - get() = Cipher.getInstance("AES/CBC/PKCS5Padding") + get() = when (mode) { + SnapCipherMode.CBC -> + Cipher.getInstance("AES/CBC/PKCS5Padding") + + SnapCipherMode.CTR -> + Cipher.getInstance("AES/CTR/NoPadding") + } - /** - * Get the key spec from the encryption wrapper - */ val keySpec: ByteArray by lazy { searchByteArrayField(32)[instance] as ByteArray } - /** - * Get the iv key parameter spec from the encryption wrapper - */ val ivKeyParameterSpec: ByteArray by lazy { searchByteArrayField(16)[instance] as ByteArray } + + private fun searchByteArrayField(arrayLength: Int): Field { + return instanceNonNull()::class.java.fields.first { f -> + try { + if (!f.type.isArray || + f.type.componentType != Byte::class.javaPrimitiveType + ) return@first false + + (f.get(instanceNonNull()) as ByteArray).size == arrayLength + } catch (_: Exception) { + false + } + } + } +} + +@OptIn(ExperimentalEncodingApi::class) +fun EncryptionWrapper.toKeyPair(): MediaEncryptionKeyPair { + return MediaEncryptionKeyPair( + key = android.util.Base64.encodeToString(this.keySpec, android.util.Base64.NO_WRAP), + iv = android.util.Base64.encodeToString(this.ivKeyParameterSpec, android.util.Base64.NO_WRAP), + urlSafe = true + ) } -@OptIn(ExperimentalEncodingApi::class) -fun EncryptionWrapper.toKeyPair() - = MediaEncryptionKeyPair(Base64.UrlSafe.encode(this.keySpec), Base64.UrlSafe.encode(this.ivKeyParameterSpec))