commit d4c8a21504ec1e4f724557b0213b8b8d949e22e6
parent 3fb05c764e5c7e3040192b200cbf8fcd360c9ccd
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Tue, 26 Aug 2025 14:13:26 +0200

feat(core): stealth mode save snap in chat

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>

Diffstat:
Mcommon/src/main/assets/lang/en_US.json | 3++-
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt | 2+-
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoMarkAsRead.kt | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt | 13++++++++-----
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/StealthMode.kt | 11++++++++---
5 files changed, 71 insertions(+), 10 deletions(-)

diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -1355,7 +1355,8 @@ }, "auto_mark_as_read": { "conversation_read": "Mark conversation as read when sending a message", - "snap_reply": "Mark snaps as read when replying to them" + "snap_reply": "Mark snaps as read when replying to them", + "save_snap_in_chat": "Mark snaps as read when saving them in chat while in Stealth Mode" }, "friend_mutation_notifier": { "remove_friend": "Notify when someone removes you as a friend", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/config/impl/MessagingTweaks.kt @@ -62,7 +62,7 @@ class MessagingTweaks : ConfigContainer() { val hideBitmojiPresence = boolean("hide_bitmoji_presence") val hideTypingNotifications = boolean("hide_typing_notifications") val unlimitedSnapViewTime = boolean("unlimited_snap_view_time") - val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read") { requireRestart() } + val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() } val markSnapAsSeenButton = boolean("mark_snap_as_seen_button") { requireRestart() } val skipWhenMarkingAsSeen = boolean("skip_when_marking_as_seen") { requireRestart() } val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() } diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoMarkAsRead.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/AutoMarkAsRead.kt @@ -9,11 +9,17 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.rhunk.snapenhance.common.data.ContentType import me.rhunk.snapenhance.common.data.MessageUpdate +import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent import me.rhunk.snapenhance.core.features.Feature import me.rhunk.snapenhance.core.features.impl.spying.StealthMode import me.rhunk.snapenhance.core.ui.ViewAppearanceHelper +import me.rhunk.snapenhance.core.util.CallbackBuilder +import me.rhunk.snapenhance.core.util.hook.HookStage +import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull +import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.mapper.impl.CallbackMapper import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.random.Random @@ -82,6 +88,52 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") { val config by context.config.messaging.autoMarkAsRead if (config.isEmpty()) return + if (config.contains("save_snap_in_chat")) { + var lastInteractedSnapClientMessageId = -1L + + context.event.subscribe(OnSnapInteractionEvent::class) { event -> + lastInteractedSnapClientMessageId = event.messageId + } + + context.classCache.conversationManager.hook("updateMessage", HookStage.BEFORE) { param -> + if (param.arg<Any>(2).toString() != "SAVE") return@hook + + val clientMessageId = param.arg<Long>(1) + if (lastInteractedSnapClientMessageId != clientMessageId) return@hook + + val conversationId = SnapUUID(param.arg(0)) + + param.setResult(null) + + val snapManager = context.feature(Messaging::class).snapManager ?: return@hook + val stealthMode = context.feature(StealthMode::class) + + // ignore non-stealth mode conversations + if (!stealthMode.canUseRule(conversationId.toString())) return@hook + + stealthMode.addSnapInteractionException(clientMessageId) + + val onSnapInteraction = snapManager.javaClass.methods.firstOrNull { it.name == "onSnapInteraction" } ?: return@hook + + context.mappings.useMapper(CallbackMapper::class) { + onSnapInteraction.invoke(snapManager, + findClass("com.snapchat.client.messaging.SnapInteractionType").enumConstants!!.first { it.toString() == "VIEWING_INITIATED" }, + conversationId.instanceNonNull(), + clientMessageId, + CallbackBuilder(callbacks.getClass("SnapInteractionCallback")!!) + .override("onSuccess") { + param.invokeOriginal() + stealthMode.addSnapInteractionException(clientMessageId) + } + .override("onError") { + context.log.verbose("error ${it.arg<Any>(0)}") + } + .build() + ) + } + } + } + context.event.subscribe(SendMessageWithContentEvent::class) { event -> event.addCallbackResult("onSuccess") { if (canMarkConversationAsRead) { diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/messaging/Messaging.kt @@ -14,11 +14,7 @@ import me.rhunk.snapenhance.core.util.hook.hook import me.rhunk.snapenhance.core.util.hook.hookConstructor import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.util.ktx.getObjectFieldOrNull -import me.rhunk.snapenhance.core.wrapper.impl.ConversationManager -import me.rhunk.snapenhance.core.wrapper.impl.Message -import me.rhunk.snapenhance.core.wrapper.impl.SnapUUID -import me.rhunk.snapenhance.core.wrapper.impl.Snapchatter -import me.rhunk.snapenhance.core.wrapper.impl.toSnapUUID +import me.rhunk.snapenhance.core.wrapper.impl.* import me.rhunk.snapenhance.mapper.impl.CallbackMapper import me.rhunk.snapenhance.mapper.impl.FriendsFeedEventDispatcherMapper import java.util.UUID @@ -27,6 +23,9 @@ import java.util.concurrent.Future class Messaging : Feature("Messaging") { var conversationManager: ConversationManager? = null private set + var snapManager: Any? = null + private set + private var conversationManagerDelegate: Any? = null private var identityDelegate: Any? = null @@ -69,6 +68,10 @@ class Messaging : Feature("Messaging") { } } + context.classCache.snapManager.hookConstructor(HookStage.BEFORE) { param -> + snapManager = param.thisObject() + } + context.mappings.useMapper(CallbackMapper::class) { callbacks.getClass("ConversationManagerDelegate")?.apply { hookConstructor(HookStage.AFTER) { param -> diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/StealthMode.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/spying/StealthMode.kt @@ -10,15 +10,19 @@ import java.util.concurrent.CopyOnWriteArraySet class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH) { private val displayedMessageQueue = CopyOnWriteArraySet<Long>() + private val snapInteractionQueue = CopyOnWriteArraySet<Long>() fun addDisplayedMessageException(clientMessageId: Long) { displayedMessageQueue.add(clientMessageId) } + fun addSnapInteractionException(messageId: Long) { + snapInteractionQueue.add(messageId) + } + + override fun init() { - val isConversationInStealthMode: (SnapUUID) -> Boolean = hook@{ - context.feature(StealthMode::class).canUseRule(it.toString()) - } + val isConversationInStealthMode: (SnapUUID) -> Boolean = { canUseRule(it.toString()) } arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String -> context.classCache.conversationManager.hook(methodName, HookStage.BEFORE) { param -> @@ -30,6 +34,7 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT } context.event.subscribe(OnSnapInteractionEvent::class) { event -> + if (snapInteractionQueue.removeIf { event.messageId == it }) return@subscribe if (isConversationInStealthMode(event.conversationId)) { event.canceled = true }