2 Commits

Author SHA1 Message Date
ΞTΞRNAL
b7c8042a93 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-24 03:37:30 +05:30
ΞTΞRNAL
141b5e16a0 feat: continuous snap feature! 2026-03-24 03:36:39 +05:30
7 changed files with 330 additions and 62 deletions

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.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.

View File

@@ -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

View File

@@ -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",

View File

@@ -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
}
}
}

View File

@@ -72,6 +72,7 @@ class MediaFilePicker : Feature("Media File Picker") {
private val queuedSplitItemIds = ArrayDeque<String>()
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
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<Any>, preparedItems: List<PreparedMediaItem>, 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<Int, Int>()
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<Any>(1) as? List<*>)?.filterNotNull() ?: return@hookObjectMethod
if (currentItems.isEmpty()) return@hookObjectMethod
reusableOriginalItem = currentItems.firstOrNull()
val itemClass = sendItems.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
?: sendItemsListItemClassFallback

View File

@@ -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<Any, Boolean>())
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<Class<*>> {
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<ByteArray>()
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<Any>(0))
}
invokeCallbackError(completionCallback, 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)
}
}
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<Any>(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
)
}
}
}) {

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.1
APP_VERSION_CODE=294
APP_VERSION_NAME=1.5.2
APP_VERSION_CODE=296
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c