From 141b5e16a0ade61e1f588a096559cdcfd39901a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=9ET=CE=9ERNAL?= Date: Tue, 24 Mar 2026 03:36:39 +0530 Subject: [PATCH] feat: continuous snap feature! --- build.gradle.kts | 4 +- changelogs-stable.txt | 4 + common/src/main/assets/lang/en_US.json | 6 + .../impl/experiments/EndToEndEncryption.kt | 5 +- .../impl/experiments/MediaFilePicker.kt | 35 +- .../features/impl/messaging/SendOverride.kt | 334 +++++++++++++++--- gradle.properties | 4 +- 7 files changed, 330 insertions(+), 62 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0bcd04ea..972b6f64 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.1").get()) -rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("294").get().toInt()) +rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.2").get()) +rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("296").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 1777352c..5f51370e 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -1,3 +1,7 @@ +## v1.5.2 +- New: Continuous snap sender feature! +- Fix: Snap send failure for E2E Chats + ## 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 diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index e5346c96..2aad7e80 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -3328,6 +3328,12 @@ "duration": "Duration: {duration}", "saveable_snap_hint": "Make Snap saveable in the chat", "single_send_hint": "Send as one snap", + "continuous_send_toggle": "Continuous snap sender", + "continuous_send_count_label": "Send count", + "continuous_send_count_placeholder": "Enter number of sends", + "continuous_send_hint": "This will send the same snap to the same recipient multiple times.", + "continuous_send_invalid_count": "Enter a valid send count greater than 0", + "continuous_send_single_send_conflict": "Continuous sending is not available while 'Send as one snap' is enabled for split media.", "unlimited_duration": "Unlimited", "schedule": "Schedule", "select_time": "Select time", diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/EndToEndEncryption.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/EndToEndEncryption.kt index 0be1daa9..c66eca5b 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/EndToEndEncryption.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/EndToEndEncryption.kt @@ -400,6 +400,7 @@ class EndToEndEncryption : MessagingRuleFeature( context.event.subscribe(SendMessageWithContentEvent::class) { event -> val messageContent = event.messageContent val destinations = event.destinations + if (messageContent.contentType != ContentType.CHAT) return@subscribe val e2eeConversations = destinations.getEndToEndConversations().takeIf { it.isNotEmpty() } ?: return@subscribe @@ -431,10 +432,6 @@ class EndToEndEncryption : MessagingRuleFeature( context.longToast(translation["encryption_failed_toast"]) } } - - if (event.messageContent.contentType == ContentType.SNAP) { - event.messageContent.contentType = ContentType.EXTERNAL_MEDIA - } } } 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 447bb728..e8c16c43 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 @@ -72,6 +72,7 @@ class MediaFilePicker : Feature("Media File Picker") { private val queuedSplitItemIds = ArrayDeque() private val queuedSplitCleanupUris = mutableMapOf() private var originalUnsplitItem: Any? = null + private var reusableOriginalItem: Any? = null private var queuedOverrideType: String? = null private var bypassSplitOnce = false private var sendSingleItemHandler: ((Any) -> Boolean)? = null @@ -80,6 +81,7 @@ class MediaFilePicker : Feature("Media File Picker") { fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty() fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty() fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null + fun hasReusableOriginalItem(): Boolean = reusableOriginalItem != null fun setQueuedOverrideType(value: String?) { queuedOverrideType = value } @@ -97,6 +99,12 @@ class MediaFilePicker : Feature("Media File Picker") { originalUnsplitItem = null queuedOverrideType = null } + fun sendReusableOriginalItem(): Boolean { + val item = reusableOriginalItem ?: return false + bypassSplitOnce = true + val sender = sendSingleItemHandler ?: return false + return sender(item) + } private fun queueSplitItems(items: List, preparedItems: List, originalItem: Any?) { clearQueuedSplitItems(deleteTempItems = false) originalUnsplitItem = originalItem @@ -172,6 +180,10 @@ class MediaFilePicker : Feature("Media File Picker") { val extractor = MediaExtractor() val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) val trackMap = mutableMapOf() + val chunkStartUs = chunkStartMs * 1000 + val chunkEndUs = chunkEndMs * 1000 + var muxerStarted = false + var wroteAnySample = false try { extractor.setDataSource(inputFile.absolutePath) @@ -201,8 +213,9 @@ class MediaFilePicker : Feature("Media File Picker") { val buffer = ByteBuffer.allocateDirect(maxBufferSize) val bufferInfo = android.media.MediaCodec.BufferInfo() muxer.start() + muxerStarted = true - extractor.seekTo(chunkStartMs * 1000, MediaExtractor.SEEK_TO_PREVIOUS_SYNC) + extractor.seekTo(chunkStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC) while (true) { bufferInfo.offset = 0 @@ -211,25 +224,36 @@ class MediaFilePicker : Feature("Media File Picker") { val sampleTimeUs = extractor.sampleTime if (sampleTimeUs < 0) break - if (sampleTimeUs >= chunkEndMs * 1000) break + if (sampleTimeUs < chunkStartUs) { + extractor.advance() + continue + } + if (sampleTimeUs >= chunkEndUs) break val sampleTrackIndex = extractor.sampleTrackIndex val muxerTrackIndex = trackMap[sampleTrackIndex] if (muxerTrackIndex != null) { - bufferInfo.presentationTimeUs = sampleTimeUs - (chunkStartMs * 1000) + bufferInfo.presentationTimeUs = sampleTimeUs - chunkStartUs bufferInfo.flags = extractor.sampleFlags muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo) + wroteAnySample = true } extractor.advance() } - outputFiles += outputFile + if (wroteAnySample) { + outputFiles += outputFile + } else { + outputFile.delete() + } } catch (throwable: Throwable) { outputFile.delete() outputFiles.forEach { it.delete() } throw throwable } finally { - runCatching { muxer.stop() } + if (muxerStarted) { + runCatching { muxer.stop() } + } runCatching { muxer.release() } runCatching { extractor.release() } } @@ -422,6 +446,7 @@ class MediaFilePicker : Feature("Media File Picker") { } val currentItems = (param.argNullable(1) as? List<*>)?.filterNotNull() ?: return@hookObjectMethod if (currentItems.isEmpty()) return@hookObjectMethod + reusableOriginalItem = currentItems.firstOrNull() val itemClass = sendItems.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull() ?: sendItemsListItemClassFallback 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 2b6706f2..71beb462 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 @@ -22,6 +22,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.ui.text.input.KeyboardType import kotlinx.coroutines.* import me.eternal.purrfectsnap.bridge.task.TaskListener import me.eternal.purrfectsnap.common.data.ContentType @@ -50,6 +53,8 @@ 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.Collections +import java.util.IdentityHashMap import java.util.Locale import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -60,6 +65,40 @@ class SendOverride : Feature("Send Override") { companion object { private const val NOTIFICATION_CHANNEL_ID = "scheduled_send" private val internalMultipartSend = ThreadLocal.withInitial { false } + private var queuedOriginalItemRepeatCount = 0 + private var queuedOriginalItemRepeatOverrideType: String? = null + + private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) { + queuedOriginalItemRepeatCount = repeatCount + queuedOriginalItemRepeatOverrideType = overrideType + MediaFilePicker.setQueuedOverrideType(overrideType) + } + + private fun clearQueuedOriginalItemRepeats() { + queuedOriginalItemRepeatCount = 0 + queuedOriginalItemRepeatOverrideType = null + } + + private fun handleQueuedOriginalItemRepeatSuccess(): Boolean { + if (queuedOriginalItemRepeatCount <= 0) { + clearQueuedOriginalItemRepeats() + return false + } + + val overrideType = queuedOriginalItemRepeatOverrideType ?: run { + clearQueuedOriginalItemRepeats() + return false + } + + queuedOriginalItemRepeatCount-- + MediaFilePicker.setQueuedOverrideType(overrideType) + val result = MediaFilePicker.sendReusableOriginalItem() + if (!result) { + queuedOriginalItemRepeatCount++ + clearQueuedOriginalItemRepeats() + } + return result + } } private var selectedType by mutableStateOf("SNAP") @@ -405,6 +444,7 @@ class SendOverride : Feature("Send Override") { val recipientName = recipientNames.joinToString(", ") event.canceled = true + event.adapter.setResult(null) fun invokeOriginalAndRestoreResult(ev: SendMessageWithContentEvent) { val result = ev.adapter.invokeOriginal() @@ -438,6 +478,20 @@ class SendOverride : Feature("Send Override") { .first { it.name == "sendMessageWithContent" } } + val originalMessageJson = context.gson.toJson(localMessageContent.instanceNonNull()) + val originalCallback = event.adapter.args().getOrNull(2) + val conversationManagerInstance by lazy { + context.feature(Messaging::class).conversationManager?.instanceNonNull() + } + + fun invokeCallbackError(callback: Any?, error: Any?) { + runCatching { + callback?.javaClass?.methods?.firstOrNull { method -> + method.name == "onError" && method.parameterCount == 1 + }?.invoke(callback, error) + } + } + fun applyOverride( targetMessageContent: MessageContent, targetReader: ProtoReader, @@ -591,21 +645,98 @@ class SendOverride : Feature("Send Override") { return true } - fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean { - val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0 + fun createMessageContentFromOriginal(): MessageContent { + return MessageContent( + context.gson.fromJson(originalMessageJson, context.classCache.localMessageContent) + ).also { messageContent -> + val visited = Collections.newSetFromMap(IdentityHashMap()) + + fun shouldScrubField(fieldName: String): Boolean { + if (fieldName == "mId") return false + return fieldName in setOf("mMessageId", "mQuotedMessageId") || + fieldName.contains("AttemptId", ignoreCase = true) || + fieldName.contains("ClientMessageId", ignoreCase = true) || + fieldName.contains("ClientId", ignoreCase = true) || + fieldName.contains("MessageUuid", ignoreCase = true) || + fieldName.contains("UUID", ignoreCase = true) + } + + fun scrubValue(value: Any?) { + if (value == null) return + if (!visited.add(value)) return + + when (value) { + is String, is Number, is Boolean, is ByteArray, is Enum<*> -> return + is Iterable<*> -> { + value.forEach { scrubValue(it) } + return + } + is Map<*, *> -> { + value.values.forEach { scrubValue(it) } + return + } + } + + sequence> { + var current: Class<*>? = value.javaClass + while (current != null && current != Any::class.java && current != Object::class.java) { + yield(current) + current = current.superclass + } + }.flatMap { it.declaredFields.asSequence() } + .forEach { field -> + runCatching { + field.isAccessible = true + if (shouldScrubField(field.name)) { + when (field.type) { + java.lang.Long.TYPE -> field.setLong(value, 0L) + java.lang.Integer.TYPE -> field.setInt(value, 0) + java.lang.Boolean.TYPE -> field.setBoolean(value, false) + else -> field.set(value, null) + } + } else { + scrubValue(field.get(value)) + } + } + } + } + + scrubValue(messageContent.instanceNonNull()) + } + } + + fun invokeSendManually(messageContent: MessageContent, callback: Any?) { + val conversationManager = conversationManagerInstance ?: error("ConversationManager is null") + internalMultipartSend.set(true) + try { + sendMessageWithContentMethod.invoke( + conversationManager, + cloneDestinations(event.destinations), + messageContent.instanceNonNull(), + callback + ) + } finally { + internalMultipartSend.set(false) + } + } + + fun sendMediaManual( + sourceMessageContent: MessageContent, + overrideType: String, + snapDurationMs: Int?, + completionCallback: Any? + ): Boolean { + val sourceReader = ProtoReader(sourceMessageContent.content ?: return false) + val mediaCount = sourceReader.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 -> + sourceReader.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 partContent = createMessageContentFromOriginal() val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata") val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf()) val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList()) @@ -637,62 +768,95 @@ class SendOverride : Feature("Send Override") { if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return val callback = if (partIndex == mediaCount - 1) { - originalCallback + completionCallback } 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)) - } + invokeCallbackError(completionCallback, 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) - } - } + invokeSendManually(partContent, callback) } sendPart(0) return true } + postSavePolicy = null + val targetReader = ProtoReader(sourceMessageContent.content ?: return false) + if (!applyOverride(sourceMessageContent, targetReader, overrideType, snapDurationMs)) return false + invokeSendManually(sourceMessageContent, completionCallback) + return true + } + + fun sendRepeatedMediaManual( + repeatCount: Int, + overrideType: String, + snapDurationMs: Int? + ): Boolean { + if (repeatCount <= 0) return false + + fun sendIteration(index: Int) { + val callback = if (index == repeatCount - 1) { + originalCallback + } else { + CallbackBuilder(sendMessageCallbackClass) + .override("onSuccess") { + sendIteration(index + 1) + } + .override("onError", shouldUnhook = false) { + invokeCallbackError(originalCallback, it.argNullable(0)) + } + .build() + } + + val preparedContent = createMessageContentFromOriginal() + if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) { + invokeCallbackError(originalCallback, "Failed to send") + } + } + + sendIteration(0) + return true + } + + fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean { + postSavePolicy = null 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() - } + + fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) { + sendEvent.addCallbackResult("onSuccess") { + context.runOnUiThread { + val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess() + val handledRepeat = if (!handledSplit) { + handleQueuedOriginalItemRepeatSuccess() + } else { + false + } + if (!handledSplit && !handledRepeat) { + MediaFilePicker.clearQueuedSplitItems() + clearQueuedOriginalItemRepeats() } } - event.addCallbackResult("onError") { - MediaFilePicker.clearQueuedSplitItems() - } + } + sendEvent.addCallbackResult("onError") { + MediaFilePicker.clearQueuedSplitItems() + clearQueuedOriginalItemRepeats() + } + } + + if (resolvedOverrideType != null) { + if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) { + attachQueuedRepeatCallbacks(event) } if (sendMedia(resolvedOverrideType, 10000)) { if (event.canceled) invokeOriginalAndRestoreResult(event) @@ -702,7 +866,7 @@ class SendOverride : Feature("Send Override") { context.runOnUiThread { val recipientNameForTask = recipientName - val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0 + val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0 createComposeAlertDialog(context.mainActivity!!) { alertDialog -> PurrfectOverlayTheme { @@ -792,6 +956,8 @@ class SendOverride : Feature("Send Override") { context.translation.getCategory("features.options.gallery_media_send_override") } var scheduleEnabled by remember { mutableStateOf(false) } + var continuousSendEnabled by remember { mutableStateOf(false) } + var continuousSendCount by remember { mutableStateOf("2") } Text( fontSize = 20.sp, @@ -907,6 +1073,42 @@ class SendOverride : Feature("Send Override") { } } + Row( + modifier = Modifier.fillMaxWidth().clickable { + continuousSendEnabled = !continuousSendEnabled + }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = continuousSendEnabled, + onCheckedChange = { + continuousSendEnabled = it + } + ) + Text(text = mainTranslation["continuous_send_toggle"], lineHeight = 15.sp) + } + + if (continuousSendEnabled) { + OutlinedTextField( + value = continuousSendCount, + onValueChange = { value -> + continuousSendCount = value.filter(Char::isDigit).take(3) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(mainTranslation["continuous_send_count_label"]) }, + placeholder = { Text(mainTranslation["continuous_send_count_placeholder"]) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + keyboardActions = KeyboardActions.Default + ) + Text( + text = mainTranslation["continuous_send_hint"], + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.72f) + ) + } + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, @@ -1071,8 +1273,27 @@ class SendOverride : Feature("Send Override") { Text(context.translation["button.cancel"]) } Button(onClick = { - alertDialog.dismiss() val finalSelectedType = selectedType + val repeatCount = if (continuousSendEnabled) { + continuousSendCount.toIntOrNull()?.takeIf { it > 0 } + } else { + 1 + } + if (repeatCount == null) { + context.inAppOverlay.showStatusToast( + icon = Icons.Default.WarningAmber, + text = mainTranslation["continuous_send_invalid_count"] + ) + return@Button + } + if (repeatCount > 1 && disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) { + context.inAppOverlay.showStatusToast( + icon = Icons.Default.WarningAmber, + text = mainTranslation["continuous_send_single_send_conflict"] + ) + return@Button + } + alertDialog.dismiss() if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) { MediaFilePicker.setQueuedOverrideType(finalSelectedType) if (!MediaFilePicker.sendOriginalUnsplitItem()) { @@ -1137,10 +1358,11 @@ class SendOverride : Feature("Send Override") { context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100) - if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { - if (event.canceled) { - invokeOriginalAndRestoreResult(event) - } + if (sendRepeatedMediaManual( + repeatCount, + finalSelectedType, + if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null + )) { val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask" context.inAppOverlay.showStatusToast( icon = Icons.Filled.CheckCircle, @@ -1187,10 +1409,24 @@ class SendOverride : Feature("Send Override") { } } } else { - if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { - if (event.canceled) { + if (repeatCount == 1) { + if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { invokeOriginalAndRestoreResult(event) } + } else if (MediaFilePicker.hasReusableOriginalItem()) { + queueOriginalItemRepeats(repeatCount - 1, finalSelectedType) + attachQueuedRepeatCallbacks(event) + if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { + invokeOriginalAndRestoreResult(event) + } else { + clearQueuedOriginalItemRepeats() + } + } else { + sendRepeatedMediaManual( + repeatCount, + finalSelectedType, + if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null + ) } } }) { diff --git a/gradle.properties b/gradle.properties index 7a05e77a..b10bcf00 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.1 -APP_VERSION_CODE=294 +APP_VERSION_NAME=1.5.2 +APP_VERSION_CODE=296 debug_build_hash=18fe2a814d0e2eb5 psIntegrityPinnedSha256= EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c