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