fix: splitting issue for snaps sent through send override

This commit is contained in:
ΞTΞRNAL
2026-03-22 20:30:34 +05:30
parent 7a8ad81d48
commit bdc6d12739
7 changed files with 574 additions and 44 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("292").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.1").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("294").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
// Include version code so each release has a different hash; use random for uniqueness within same version.

View File

@@ -1,3 +1,7 @@
## v1.5.1
- Fix: Splitting issue for video snaps sent through gallery media send override!
- New: Toggle to turn off/on splitting for video snaps sent through send override
## v1.5.0
- Fix: Skip when marking as seen for newer versions of Snapchat
- New: Hide Conversation Toolbox UI

View File

@@ -3327,6 +3327,7 @@
"title": "Send media as",
"duration": "Duration: {duration}",
"saveable_snap_hint": "Make Snap saveable in the chat",
"single_send_hint": "Send as one snap",
"unlimited_duration": "Unlimited",
"schedule": "Schedule",
"select_time": "Select time",

View File

@@ -2,12 +2,18 @@ package me.eternal.purrfectsnap.core.features.impl.experiments
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentUris
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Intent
import android.database.Cursor
import android.database.CursorWrapper
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.webkit.MimeTypeMap
@@ -36,6 +42,7 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.FileType
import me.eternal.purrfectsnap.common.ui.createComposeView
@@ -47,17 +54,284 @@ import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.mapper.impl.ChatMediaDrawerMapper
import java.io.File
import java.io.InputStream
import java.lang.reflect.Method
import java.nio.ByteBuffer
import kotlin.random.Random
class MediaFilePicker : Feature("Media File Picker") {
companion object {
private const val SNAP_CHUNK_DURATION_MS = 10_000L
private val queuedSplitItems = ArrayDeque<Any>()
private val queuedSplitItemIds = ArrayDeque<String>()
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
private var originalUnsplitItem: Any? = null
private var queuedOverrideType: String? = null
private var bypassSplitOnce = false
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
private var cleanupItemHandler: ((String) -> Unit)? = null
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
fun setQueuedOverrideType(value: String?) {
queuedOverrideType = value
}
fun getQueuedOverrideType(): String? = queuedOverrideType
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
if (deleteTempItems) {
val cleanup = cleanupItemHandler
queuedSplitCleanupUris.values.toList().forEach { uri ->
cleanup?.invoke(uri)
}
}
queuedSplitItems.clear()
queuedSplitItemIds.clear()
queuedSplitCleanupUris.clear()
originalUnsplitItem = null
queuedOverrideType = null
}
private fun queueSplitItems(items: List<Any>, preparedItems: List<PreparedMediaItem>, originalItem: Any?) {
clearQueuedSplitItems(deleteTempItems = false)
originalUnsplitItem = originalItem
items.drop(1).forEach { queuedSplitItems.addLast(it) }
preparedItems.forEach {
queuedSplitItemIds.addLast(it.itemId)
queuedSplitCleanupUris[it.itemId] = it.uri
}
}
fun sendOriginalUnsplitItem(): Boolean {
val item = originalUnsplitItem ?: return false
val overrideType = queuedOverrideType
clearQueuedSplitItems(deleteTempItems = true)
queuedOverrideType = overrideType
bypassSplitOnce = true
val sender = sendSingleItemHandler ?: return false
return sender(item)
}
fun handleCurrentQueuedItemSuccess(): Boolean {
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
cleanupItemHandler?.invoke(uri)
}
}
if (queuedSplitItems.isEmpty()) {
queuedOverrideType = null
return false
}
val next = queuedSplitItems.removeFirstOrNull() ?: run {
queuedOverrideType = null
return false
}
val sender = sendSingleItemHandler ?: return false
val result = sender(next)
if (!result) {
queuedSplitItems.addFirst(next)
}
return result
}
}
var lastMediaDuration: Long? = null
private set
private data class PreparedMediaItem(
val itemId: String,
val durationMs: Long,
val uri: String
)
private fun splitVideoIntoChunks(
inputFile: File,
chunkDurationMs: Long = SNAP_CHUNK_DURATION_MS
): List<File> {
val durationMs = extractMediaDuration(Uri.fromFile(inputFile)) ?: return emptyList()
if (durationMs <= chunkDurationMs) return listOf(inputFile)
val retriever = MediaMetadataRetriever()
val rotation = runCatching {
retriever.setDataSource(inputFile.absolutePath)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
}.getOrDefault(0).also {
runCatching { retriever.release() }
}
val outputFiles = mutableListOf<File>()
var chunkStartMs = 0L
var chunkIndex = 0
while (chunkStartMs < durationMs) {
val chunkEndMs = minOf(chunkStartMs + chunkDurationMs, durationMs)
val outputFile = File.createTempFile("purrfectsnap_chunk_${chunkIndex}_", ".mp4", context.androidContext.cacheDir)
val extractor = MediaExtractor()
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val trackMap = mutableMapOf<Int, Int>()
try {
extractor.setDataSource(inputFile.absolutePath)
repeat(extractor.trackCount) { trackIndex ->
val format = extractor.getTrackFormat(trackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: return@repeat
if (!mime.startsWith("video/") && !mime.startsWith("audio/")) return@repeat
extractor.selectTrack(trackIndex)
trackMap[trackIndex] = muxer.addTrack(format)
}
if (rotation != 0) {
muxer.setOrientationHint(rotation)
}
val maxBufferSize = (0 until extractor.trackCount).maxOfOrNull { trackIndex ->
extractor.getTrackFormat(trackIndex).let { format ->
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
} else {
1024 * 1024
}
}
} ?: (1024 * 1024)
val buffer = ByteBuffer.allocateDirect(maxBufferSize)
val bufferInfo = android.media.MediaCodec.BufferInfo()
muxer.start()
extractor.seekTo(chunkStartMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
while (true) {
bufferInfo.offset = 0
bufferInfo.size = extractor.readSampleData(buffer, 0)
if (bufferInfo.size < 0) break
val sampleTimeUs = extractor.sampleTime
if (sampleTimeUs < 0) break
if (sampleTimeUs >= chunkEndMs * 1000) break
val sampleTrackIndex = extractor.sampleTrackIndex
val muxerTrackIndex = trackMap[sampleTrackIndex]
if (muxerTrackIndex != null) {
bufferInfo.presentationTimeUs = sampleTimeUs - (chunkStartMs * 1000)
bufferInfo.flags = extractor.sampleFlags
muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo)
}
extractor.advance()
}
outputFiles += outputFile
} catch (throwable: Throwable) {
outputFile.delete()
outputFiles.forEach { it.delete() }
throw throwable
} finally {
runCatching { muxer.stop() }
runCatching { muxer.release() }
runCatching { extractor.release() }
}
chunkStartMs += chunkDurationMs
chunkIndex++
}
return outputFiles
}
private fun registerTemporaryVideo(file: File, displayName: String): PreparedMediaItem {
val resolver = context.androidContext.contentResolver
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, displayName)
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/.PurrfectSnap")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.IS_PENDING, 1)
}
}
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
?: error("Failed to create MediaStore entry")
runCatching {
resolver.openOutputStream(uri)?.use { output ->
file.inputStream().use { input -> input.copyTo(output) }
} ?: error("Failed to open MediaStore output stream")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.update(uri, ContentValues().apply {
put(MediaStore.Video.Media.IS_PENDING, 0)
}, null, null)
}
}.onFailure {
resolver.delete(uri, null, null)
throw it
}
val durationMs = extractMediaDuration(uri) ?: 0L
val itemId = uri.lastPathSegment ?: error("Failed to resolve MediaStore item id")
context.coroutineScope.launch {
delay(120_000)
runCatching { resolver.delete(uri, null, null) }
}
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
}
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
return mediaItems.mapIndexedNotNull { index, mediaItem ->
itemClass.dataBuilder {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", mediaItem.durationMs.toDouble())
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
set("_height", 1920.0)
set("_timestampMs", (System.currentTimeMillis() + index).toDouble())
from("_itemId") {
set("_itemId", mediaItem.itemId)
set("_type", "VIDEO")
}
}
set("_order", index.toDouble())
}
}
}
private fun prepareChunkedItemsFromMediaStoreId(itemId: String, durationMs: Long): List<PreparedMediaItem>? {
val numericId = itemId.toLongOrNull() ?: return null
val effectiveDurationMs = durationMs.takeIf { it > 0 } ?: extractMediaDuration(
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
) ?: return null
if (effectiveDurationMs <= SNAP_CHUNK_DURATION_MS) return null
val sourceUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
val sourceFile = File.createTempFile("purrfectsnap_gallery_source_", ".mp4", context.androidContext.cacheDir)
return runCatching {
context.androidContext.contentResolver.openInputStream(sourceUri)?.use { input ->
sourceFile.outputStream().use { output -> input.copyTo(output) }
} ?: error("Failed to open source gallery video")
val chunkFiles = splitVideoIntoChunks(sourceFile, SNAP_CHUNK_DURATION_MS)
val preparedItems = chunkFiles.mapIndexed { index, file ->
registerTemporaryVideo(file, "purrfectsnap_gallery_chunk_${System.currentTimeMillis()}_$index.mp4")
}
chunkFiles.forEach { if (it != sourceFile) it.delete() }
preparedItems
}.also {
sourceFile.delete()
}.getOrElse {
context.log.error("Failed to prepare split gallery items", it)
null
}
}
private fun extractMediaDuration(uri: Uri): Long? {
val retriever = MediaMetadataRetriever()
return runCatching {
@@ -96,6 +370,7 @@ class MediaFilePicker : Feature("Media File Picker") {
var sendItemsMethod: Method? = null
var drawerViewClass: Class<*>? = null
var sendItemsListItemClassFallback: Class<*>? = null
var sendItemsHookedHandler: Any? = null
context.mappings.useMapper(ChatMediaDrawerMapper::class) {
val drawerCls = chatMediaDrawerClass.getAsClass() ?: return@useMapper
@@ -115,6 +390,71 @@ class MediaFilePicker : Feature("Media File Picker") {
sendItemsMethod = sendItems
handlerParamMethod.hook(HookStage.AFTER) {
chatMediaDrawerActionHandler = it.arg(0)
val handlerInstance = chatMediaDrawerActionHandler
sendSingleItemHandler = sendSingleItem@{ item ->
runCatching {
sendItemsMethod?.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
true
}.getOrElse { throwable ->
context.log.error("MediaFilePicker: Failed to send queued split item", throwable)
false
}
}
cleanupItemHandler = { uriString ->
runCatching {
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
}.onFailure {
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
}
}
if (sendItemsHookedHandler === handlerInstance) return@hook
sendItemsHookedHandler = handlerInstance
Hooker.hookObjectMethod(
handlerInstance::class.java,
handlerInstance,
sendItemsName,
HookStage.BEFORE
) { param ->
if (bypassSplitOnce) {
bypassSplitOnce = false
return@hookObjectMethod
}
val currentItems = (param.argNullable<Any>(1) as? List<*>)?.filterNotNull() ?: return@hookObjectMethod
if (currentItems.isEmpty()) return@hookObjectMethod
val itemClass = sendItems.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
?: sendItemsListItemClassFallback
?: currentItems.firstOrNull()?.javaClass
?: return@hookObjectMethod
val preparedExpandedItems = mutableListOf<PreparedMediaItem>()
var didExpand = false
val expandedItems = currentItems.flatMap { item ->
val baseItem = item.getObjectFieldOrNull("_item") ?: return@flatMap listOf(item)
val durationMs = ((baseItem.getObjectFieldOrNull("_durationMs") as? Double)?.toLong())
?: ((baseItem.getObjectFieldOrNull("_durationMs") as? Long))
?: 0L
val itemId = baseItem.getObjectFieldOrNull("_itemId")
?.getObjectFieldOrNull("_itemId")
?.toString()
?: return@flatMap listOf(item)
val splitItems = prepareChunkedItemsFromMediaStoreId(itemId, durationMs)
if (splitItems.isNullOrEmpty()) {
listOf(item)
} else {
didExpand = true
preparedExpandedItems.addAll(splitItems)
buildDrawerItems(itemClass, splitItems)
}
}
if (didExpand && expandedItems.isNotEmpty()) {
queueSplitItems(expandedItems, preparedExpandedItems, currentItems.firstOrNull())
param.setArg(1, listOf(expandedItems.first()))
}
}
}
}
@@ -171,7 +511,7 @@ class MediaFilePicker : Feature("Media File Picker") {
return@subscribe
}
fun sendMedia() {
fun sendMedia(items: List<PreparedMediaItem>? = null) {
val method = sendItemsMethod ?: return
val itemClass = method.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
?: sendItemsListItemClassFallback
@@ -180,27 +520,13 @@ class MediaFilePicker : Feature("Media File Picker") {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media (incompatible version).")
return
}
val item = itemClass.dataBuilder {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", (lastMediaDuration ?: 0L).toDouble())
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
set("_height", 1920.0)
set("_timestampMs", System.currentTimeMillis().toDouble())
from("_itemId") {
set("_itemId", firstVideoId.toString())
set("_type", "VIDEO")
}
}
set("_order", 0.0)
} ?: run {
val mediaItems = items ?: listOf(PreparedMediaItem(firstVideoId.toString(), lastMediaDuration ?: 0L, ""))
val builtItems = buildDrawerItems(itemClass, mediaItems)
if (builtItems.size != mediaItems.size) {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to build media item.")
return
}
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), builtItems)
}
fun startConversion(audioOnly: Boolean) {
@@ -238,8 +564,25 @@ class MediaFilePicker : Feature("Media File Picker") {
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
runCatching {
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
sendMedia()
if (!audioOnly && (lastMediaDuration ?: 0L) > 10_000L) {
val convertedFile = File.createTempFile("purrfectsnap_source_", ".$outputExtension", context.androidContext.cacheDir)
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
convertedFile.outputStream().use { output -> input.copyTo(output) }
}
val chunkFiles = splitVideoIntoChunks(convertedFile)
val preparedItems = chunkFiles.mapIndexed { index, file ->
registerTemporaryVideo(file, "purrfectsnap_chunk_${System.currentTimeMillis()}_$index.mp4")
}
chunkFiles.forEach { if (it != convertedFile) it.delete() }
convertedFile.delete()
sendMedia(preparedItems)
} else {
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
sendMedia()
}
}.onFailure {
mediaInputStream = null
context.log.error(it)

View File

@@ -32,17 +32,22 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.experiments.MediaFilePicker
import me.eternal.purrfectsnap.core.messaging.MessageSender
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.wrapper.impl.MessageContent
import me.eternal.purrfectsnap.core.wrapper.impl.MessageDestinations
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import me.eternal.purrfectsnap.core.util.CallbackBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@@ -54,9 +59,11 @@ import kotlin.time.toDuration
class SendOverride : Feature("Send Override") {
companion object {
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
private val internalMultipartSend = ThreadLocal.withInitial { false }
}
private var selectedType by mutableStateOf("SNAP")
private var disableSplitForCurrentSend by mutableStateOf(false)
private var customDuration by mutableFloatStateOf(10f)
private var scheduledTime by mutableStateOf<Long?>(null)
private var showClockPicker by mutableStateOf(false)
@@ -66,7 +73,6 @@ class SendOverride : Feature("Send Override") {
private val backgroundHookLock = Any()
private var backgroundHookRefs = 0
private var backgroundHooks: List<Hooker.HookHandle>? = null
private fun acquireScheduledSendBackground(): () -> Unit {
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
var enableFailed = false
@@ -362,7 +368,12 @@ class SendOverride : Feature("Send Override") {
}
}
context.event.subscribe(UnaryCallEvent::class, priority = 100) { event ->
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
}
context.event.subscribe(SendMessageWithContentEvent::class, priority = -100) { event ->
if (internalMultipartSend.get() == true) return@subscribe
postSavePolicy = null
if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe
val localMessageContent = event.messageContent
@@ -401,9 +412,40 @@ class SendOverride : Feature("Send Override") {
ev.canceled = false
}
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
val sendMessageCallbackClass by lazy {
lateinit var result: Class<*>
context.mappings.useMapper(CallbackMapper::class) {
result = callbacks.getClass("SendMessageCallback") ?: error("Failed to resolve SendMessageCallback")
}
result
}
fun cloneDestinations(source: MessageDestinations): Any {
return context.gson.fromJson(
context.gson.toJson(source.instanceNonNull()),
context.classCache.messageDestinations
)
}
val sendMessageWithContentMethod by lazy {
sequence {
var current: Class<*>? = context.classCache.conversationManager
while (current != null && current != Any::class.java && current != Object::class.java) {
yield(current)
current = current.superclass
}
}.flatMap { it.declaredMethods.asSequence() }
.first { it.name == "sendMessageWithContent" }
}
fun applyOverride(
targetMessageContent: MessageContent,
targetReader: ProtoReader,
overrideType: String,
snapDurationMs: Int?
): Boolean {
val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get()
if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) {
if (overrideType != "ORIGINAL" && !bypassLimit && (targetReader.followPath(3)?.getCount(3) ?: 0) > 1) {
context.inAppOverlay.showStatusToast(
icon = Icons.Default.WarningAmber,
context.translation["gallery_media_send_override.multiple_media_toast"]
@@ -416,10 +458,10 @@ class SendOverride : Feature("Send Override") {
val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 2 else 1
postSavePolicy = savePolicyValue
val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer()
val extras = targetReader.followPath(3, 3, 13)?.getBuffer()
if (localMessageContent.contentType != ContentType.SNAP) {
localMessageContent.content = ProtoWriter().apply {
if (targetMessageContent.contentType != ContentType.SNAP) {
targetMessageContent.content = ProtoWriter().apply {
from(11) {
from(5) {
from(1) {
@@ -440,11 +482,11 @@ class SendOverride : Feature("Send Override") {
}.toByteArray()
}
localMessageContent.contentType = ContentType.SNAP
localMessageContent.content = ProtoEditor(localMessageContent.content!!).apply {
targetMessageContent.contentType = ContentType.SNAP
targetMessageContent.content = ProtoEditor(targetMessageContent.content!!).apply {
edit(11, 5, 2) {
arrayOf(6, 7, 8).forEach { remove(it) }
addVarInt(5, messageProtoReader.getVarInt(3, 3, 5, 2, 5) ?: messageProtoReader.getVarInt(11, 5, 2, 5) ?: 1)
addVarInt(5, targetReader.getVarInt(3, 3, 5, 2, 5) ?: targetReader.getVarInt(11, 5, 2, 5) ?: 1)
if (snapDurationMs != null && overrideType != "SAVEABLE_SNAP") {
addVarInt(8, snapDurationMs / 1000)
if (snapDurationMs / 1000 <= 0) {
@@ -474,11 +516,11 @@ class SendOverride : Feature("Send Override") {
if (shouldPreventSave) {
postSavePolicy = 1 // PROHIBITED
}
localMessageContent.contentType = ContentType.NOTE
targetMessageContent.contentType = ContentType.NOTE
val stripMeta = context.config.messaging.stripMediaMetadata.get()
val omitTranscript = stripMeta.contains("remove_audio_note_transcript_capability")
val rawDurationMs = messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
?: messageProtoReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
val rawDurationMs = targetReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
?: targetReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
?: (context.feature(MediaFilePicker::class).lastMediaDuration ?: 0).toLong()
val durationForProto = minOf(rawDurationMs, MessageSender.VOICE_NOTE_MAX_DURATION_MS)
val audioNoteProto = MessageSender.audioNoteProto(
@@ -487,7 +529,7 @@ class SendOverride : Feature("Send Override") {
)
// Set save policy in the proto if prevent audio is enabled
localMessageContent.content = if (shouldPreventSave) {
targetMessageContent.content = if (shouldPreventSave) {
// Check which path structure exists in the audio note proto
val protoReader = ProtoReader(audioNoteProto)
val hasNestedPath = protoReader.followPath(6, 1, 1) != null
@@ -519,7 +561,7 @@ class SendOverride : Feature("Send Override") {
Class.forName(
"com.snapchat.client.messaging.SavePolicy",
false,
localMessageContent.instanceNonNull().javaClass.classLoader
targetMessageContent.instanceNonNull().javaClass.classLoader
)
}.getOrNull()
@@ -537,7 +579,7 @@ class SendOverride : Feature("Send Override") {
}.getOrNull()
if (policyEnum != null) {
localMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
targetMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
}
}
}
@@ -549,10 +591,111 @@ class SendOverride : Feature("Send Override") {
return true
}
val resolvedOverrideType = configOverrideType?.takeIf { it != "always_ask" }
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
if (overrideType != "ORIGINAL" && mediaCount > 1) {
val originalJson = context.gson.toJson(localMessageContent.instanceNonNull())
val originalCallback = event.adapter.args().getOrNull(2)
val mediaBuffers = mutableListOf<ByteArray>()
messageProtoReader.followPath(3)?.eachBuffer { id, buffer ->
if (id == 3) mediaBuffers.add(buffer)
}
if (mediaBuffers.isEmpty()) return false
fun buildPartMessageContent(partIndex: Int): MessageContent {
val partContent = MessageContent(
context.gson.fromJson(originalJson, context.classCache.localMessageContent)
)
val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata")
val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf())
val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList())
val encryptionRefs = (metadata?.getObjectFieldOrNull("mRemoteMediaEncryption") as? ArrayList<*>)?.toCollection(ArrayList())
partContent.content = ProtoEditor(partContent.content!!).apply {
edit(3) {
remove(3)
addBuffer(3, mediaBuffers[partIndex])
}
}.toByteArray()
if (partIndex < refs.size) {
partContent.localMediaReferences = arrayListOf(refs[partIndex])
}
metadata?.let {
if (contentRefs != null && partIndex < contentRefs.size) {
it.setObjectField("mContentReferences", arrayListOf(contentRefs[partIndex]))
}
if (encryptionRefs != null && partIndex < encryptionRefs.size) {
it.setObjectField("mRemoteMediaEncryption", arrayListOf(encryptionRefs[partIndex]))
}
}
return partContent
}
fun sendPart(partIndex: Int) {
postSavePolicy = null
val partContent = buildPartMessageContent(partIndex)
val partReader = ProtoReader(partContent.content ?: return)
if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return
val callback = if (partIndex == mediaCount - 1) {
originalCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendPart(partIndex + 1)
}
.override("onError", shouldUnhook = false) {
runCatching {
originalCallback?.javaClass?.methods?.firstOrNull { method ->
method.name == "onError" && method.parameterCount == 1
}?.invoke(originalCallback, it.argNullable<Any>(0))
}
}
.build()
}
if (partIndex == 0) {
event.adapter.setArg(1, partContent.instanceNonNull())
event.adapter.setArg(2, callback)
invokeOriginalAndRestoreResult(event)
} else {
internalMultipartSend.set(true)
try {
sendMessageWithContentMethod.invoke(
context.feature(Messaging::class).conversationManager?.instanceNonNull(),
cloneDestinations(event.destinations),
partContent.instanceNonNull(),
callback
)
} finally {
internalMultipartSend.set(false)
}
}
}
sendPart(0)
return true
}
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
}
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
?: configOverrideType?.takeIf { it != "always_ask" }
if (resolvedOverrideType != null) {
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null) {
event.addCallbackResult("onSuccess") {
context.runOnUiThread {
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
MediaFilePicker.clearQueuedSplitItems()
}
}
}
event.addCallbackResult("onError") {
MediaFilePicker.clearQueuedSplitItems()
}
}
if (sendMedia(resolvedOverrideType, 10000)) {
invokeOriginalAndRestoreResult(event)
if (event.canceled) invokeOriginalAndRestoreResult(event)
}
return@subscribe
}
@@ -713,6 +856,21 @@ class SendOverride : Feature("Send Override") {
fun toggleSaveable() {
selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP"
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
disableSplitForCurrentSend = !disableSplitForCurrentSend
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = disableSplitForCurrentSend,
onCheckedChange = {
disableSplitForCurrentSend = it
}
)
Text(text = mainTranslation["single_send_hint"], lineHeight = 15.sp)
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
toggleSaveable()
@@ -915,6 +1073,25 @@ class SendOverride : Feature("Send Override") {
Button(onClick = {
alertDialog.dismiss()
val finalSelectedType = selectedType
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(null)
}
return@Button
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
event.addCallbackResult("onSuccess") {
context.runOnUiThread {
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
MediaFilePicker.clearQueuedSplitItems()
}
}
}
event.addCallbackResult("onError") {
MediaFilePicker.clearQueuedSplitItems()
}
}
val delayMs = scheduledTime?.let { it - System.currentTimeMillis() }
if (delayMs != null && delayMs > 0) {
val taskHash = java.util.UUID.randomUUID().toString()
@@ -961,7 +1138,9 @@ class SendOverride : Feature("Send Override") {
context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100)
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
if (event.canceled) {
invokeOriginalAndRestoreResult(event)
}
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
context.inAppOverlay.showStatusToast(
icon = Icons.Filled.CheckCircle,
@@ -1009,7 +1188,9 @@ class SendOverride : Feature("Send Override") {
}
} else {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
if (event.canceled) {
invokeOriginalAndRestoreResult(event)
}
}
}
}) {

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.5.0
APP_VERSION_CODE=292
APP_VERSION_NAME=1.5.1
APP_VERSION_CODE=294
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c