diff --git a/.gitignore b/.gitignore index a0723b05..c5462867 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ security/allowed_codes.local.* valdi/node_modules/ hs_err_pid*.log replay_pid*.log +.vs \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 924b213e..0bcd04ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,8 @@ tasks.register("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. diff --git a/changelogs-stable.txt b/changelogs-stable.txt index d7d3aa21..1777352c 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -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 diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index a3e08118..e5346c96 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -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", diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt index a5602bcb..447bb728 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt @@ -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() + private val queuedSplitItemIds = ArrayDeque() + private val queuedSplitCleanupUris = mutableMapOf() + 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, preparedItems: List, 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 { + 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() + 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() + + 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): List { + 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? { + 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(), 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(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() + 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? = 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(), listOf(item)) + method.invoke(chatMediaDrawerActionHandler, listOf(), 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) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt index 32efa130..2b6706f2 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt @@ -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(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? = 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() + 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(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) + } } } }) { diff --git a/gradle.properties b/gradle.properties index dfd8f5b9..7a05e77a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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