This commit is contained in:
Javalsta
2026-04-10 23:26:21 +01:00
parent b44558babd
commit 6d1eb89adf
11 changed files with 238 additions and 37 deletions

4
.gitignore vendored
View File

@@ -4,9 +4,11 @@ local.properties
/.idea/
.DS_Store
/build
**/build/
/captures
.externalNativeBuild
.cxx
.local/
native/.omvll/
cloudflare/**/.wrangler/
cloudflare/**/node_modules/
@@ -20,4 +22,4 @@ security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs
.vs

View File

@@ -4,6 +4,9 @@ import me.eternal.purrfectsnap.common.data.FriendStreaks
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.isStealthRule
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
import me.eternal.purrfectsnap.common.util.ktx.getInteger
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
@@ -154,12 +157,42 @@ fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
context.log.error("Failed to parse rule", it)
}
}
rules
rules.normalizeStealthRules().toList()
}
}
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
executeAsync {
val ruleType = MessagingRuleType.getByName(type)
if (ruleType?.isStealthRule() == true) {
val updatedStealthRules = getRules(targetUuid)
.withNormalizedRuleToggle(ruleType, enabled)
.filter { it.isStealthRule() }
database.beginTransaction()
try {
database.execSQL(
"DELETE FROM rules WHERE targetUuid = ? AND type IN (?, ?, ?)",
arrayOf(
targetUuid,
MessagingRuleType.STEALTH.key,
MessagingRuleType.SNAP_STEALTH.key,
MessagingRuleType.CHAT_STEALTH.key
)
)
updatedStealthRules.forEach { stealthRule ->
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
arrayOf(targetUuid, stealthRule.key)
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
return@executeAsync
}
if (enabled) {
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",

View File

@@ -37,6 +37,8 @@ import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
import me.eternal.purrfectsnap.common.ui.AutoClearKeyboardFocus
import me.eternal.purrfectsnap.common.ui.EditNoteTextField
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
@@ -222,18 +224,30 @@ class ManageScope: Routes.Route() {
Spacer(modifier = Modifier.height(16.dp))
val rules = rememberAsyncMutableStateList(listOf()) {
context.database.getRules(id)
context.database.getRules(id).normalizeStealthRules().toList()
}
fun updateRules(ruleType: MessagingRuleType, enabled: Boolean) {
val previousRules = rules.toSet()
val updatedRules = previousRules.withNormalizedRuleToggle(ruleType, enabled)
val changedRules = (previousRules + updatedRules).filter { rule ->
(rule in previousRules) != (rule in updatedRules)
}
rules.clear()
rules.addAll(updatedRules)
changedRules.forEach { changedRule ->
context.database.setRule(id, changedRule.key, changedRule in updatedRules)
}
}
SectionTitle(translation["rules_title"])
ContentCard {
MessagingRuleType.entries.forEach { ruleType ->
var ruleEnabled by remember(rules.size) {
mutableStateOf(rules.any { it.key == ruleType.key })
}
val ruleState = context.config.root.rules.getRuleState(ruleType)
val ruleEnabled = rules.any { it.key == ruleType.key }
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -252,8 +266,7 @@ class ManageScope: Routes.Route() {
checked = ruleEnabled,
enabled = if (ruleType.listMode) ruleState != null else true,
onCheckedChange = {
context.database.setRule(id, ruleType.key, it)
ruleEnabled = it
updateRules(ruleType, it)
},
colors = purrfectSwitchColors()
)

View File

@@ -779,11 +779,27 @@
}
},
"stealth": {
"name": "Stealth Mode",
"description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations",
"name": "Full Stealth Mode",
"description": "Applies both chat stealth and snap stealth for this conversation",
"options": {
"blacklist": "Exclude from Stealth Mode",
"whitelist": "Stealth mode"
"blacklist": "Exclude from Full Stealth Mode",
"whitelist": "Full Stealth Mode"
}
},
"snap_stealth": {
"name": "Snap Stealth Mode",
"description": "Prevents anyone from knowing you've opened their snaps",
"options": {
"blacklist": "Exclude from Snap Stealth Mode",
"whitelist": "Snap Stealth Mode"
}
},
"chat_stealth": {
"name": "Chat Stealth Mode",
"description": "Prevents anyone from knowing you've opened their chats or viewed their chat presence",
"options": {
"blacklist": "Exclude from Chat Stealth Mode",
"whitelist": "Chat Stealth Mode"
}
},
"auto_save": {
@@ -1885,7 +1901,13 @@
"name": "Auto Download"
},
"stealth": {
"name": "Stealth Mode"
"name": "Full Stealth Mode"
},
"snap_stealth": {
"name": "Snap Stealth Mode"
},
"chat_stealth": {
"name": "Chat Stealth Mode"
},
"auto_save": {
"name": "Auto Save"
@@ -2291,7 +2313,7 @@
"auto_save": "\ud83d\udcac Auto Save Messages",
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
"stealth": "\ud83d\udc7b Stealth Mode",
"stealth": "\ud83d\udc7b Full Stealth Mode",
"auto_reply": "\ud83d\udce8 Auto Reply",
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
"mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen",
@@ -2385,6 +2407,16 @@
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"snap_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"chat_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"auto_save": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",

View File

@@ -818,11 +818,27 @@
}
},
"stealth": {
"name": "Stealth Mode",
"description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations",
"name": "Full Stealth Mode",
"description": "Applies both chat stealth and snap stealth for this conversation",
"options": {
"blacklist": "Exclude from Stealth Mode",
"whitelist": "Stealth mode"
"blacklist": "Exclude from Full Stealth Mode",
"whitelist": "Full Stealth Mode"
}
},
"snap_stealth": {
"name": "Snap Stealth Mode",
"description": "Prevents anyone from knowing you've opened their snaps",
"options": {
"blacklist": "Exclude from Snap Stealth Mode",
"whitelist": "Snap Stealth Mode"
}
},
"chat_stealth": {
"name": "Chat Stealth Mode",
"description": "Prevents anyone from knowing you've opened their chats or viewed their chat presence",
"options": {
"blacklist": "Exclude from Chat Stealth Mode",
"whitelist": "Chat Stealth Mode"
}
},
"auto_save": {
@@ -2012,7 +2028,13 @@
"name": "Auto Download"
},
"stealth": {
"name": "Stealth Mode"
"name": "Full Stealth Mode"
},
"snap_stealth": {
"name": "Snap Stealth Mode"
},
"chat_stealth": {
"name": "Chat Stealth Mode"
},
"auto_save": {
"name": "Auto Save"
@@ -2820,7 +2842,7 @@
"auto_save": "\ud83d\udcac Auto Save Messages",
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
"stealth": "\ud83d\udc7b Stealth Mode",
"stealth": "\ud83d\udc7b Full Stealth Mode",
"auto_reply": "\ud83d\udce8 Auto Reply",
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
"mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read",
@@ -2927,6 +2949,16 @@
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"snap_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"chat_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"auto_save": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",

View File

@@ -49,6 +49,8 @@ enum class MessagingRuleType(
val configNotices: Array<FeatureNotice> = emptyArray()
) {
STEALTH("stealth", true, Icons.Outlined.TrackChanges),
SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = false),
CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = false),
HIDE_TYPING_INDICATOR("hide_typing_indicator", true, Icons.Outlined.KeyboardHide, defaultValue = "whitelist"),
AUTO_DOWNLOAD("auto_download", true, Icons.Outlined.DownloadForOffline),
AUTO_SAVE("auto_save", true, Icons.Outlined.Save, defaultValue = "blacklist"),
@@ -71,6 +73,58 @@ enum class MessagingRuleType(
}
}
private val partialStealthRuleTypes = setOf(
MessagingRuleType.SNAP_STEALTH,
MessagingRuleType.CHAT_STEALTH
)
private val allStealthRuleTypes = partialStealthRuleTypes + MessagingRuleType.STEALTH
fun MessagingRuleType.isStealthRule(): Boolean = this in allStealthRuleTypes
fun Collection<MessagingRuleType>.normalizeStealthRules(): Set<MessagingRuleType> {
val normalizedRules = toMutableSet()
if (MessagingRuleType.STEALTH in normalizedRules) {
normalizedRules.removeAll(partialStealthRuleTypes)
return normalizedRules
}
if (partialStealthRuleTypes.all { it in normalizedRules }) {
normalizedRules.removeAll(partialStealthRuleTypes)
normalizedRules.add(MessagingRuleType.STEALTH)
}
return normalizedRules
}
fun Collection<MessagingRuleType>.withNormalizedRuleToggle(
ruleType: MessagingRuleType,
enabled: Boolean
): Set<MessagingRuleType> {
val updatedRules = toMutableSet().apply {
if (enabled) {
add(ruleType)
} else {
remove(ruleType)
}
}
if (!ruleType.isStealthRule()) {
return updatedRules
}
if (enabled) {
when (ruleType) {
MessagingRuleType.STEALTH -> updatedRules.removeAll(partialStealthRuleTypes)
MessagingRuleType.SNAP_STEALTH,
MessagingRuleType.CHAT_STEALTH -> updatedRules.remove(MessagingRuleType.STEALTH)
else -> Unit
}
}
return updatedRules.normalizeStealthRules()
}
@Parcelize
data class FriendStreaks(
val notify: Boolean = true,

View File

@@ -257,8 +257,8 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
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
// ignore conversations without snap stealth enabled
if (!stealthMode.canUseSnapStealth(conversationId.toString())) return@hook
stealthMode.addSnapInteractionException(clientMessageId)

View File

@@ -62,7 +62,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE)
if (messaging.openedConversationUUID?.toString() != targetConversationId) return false
}
if (context.feature(StealthMode::class).canUseRule(targetConversationId)) return false
if (context.feature(StealthMode::class).canUseChatStealth(targetConversationId)) return false
return canUseRule(targetConversationId)
}

View File

@@ -57,7 +57,7 @@ class Messaging : Feature("Messaging") {
private fun shouldHideBitmojiPresence(stealthMode: StealthMode): Boolean {
return context.config.messaging.hideBitmojiPresence.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) } == true
}
private fun shouldSpoofViewingGalleryPresence(stealthMode: StealthMode): Boolean {
@@ -70,12 +70,12 @@ class Messaging : Feature("Messaging") {
private fun shouldHideTyping(stealthMode: StealthMode, hideTypingIndicator: HideTypingIndicator): Boolean {
return context.config.messaging.hideTypingNotifications.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) || hideTypingIndicator.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) || hideTypingIndicator.canUseRule(it) } == true
}
private fun shouldHidePeek(stealthMode: StealthMode): Boolean {
return context.config.messaging.hidePeekAPeek.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) } == true
}
private fun clearField(instance: Any, typeNamePart: String, shouldClear: Boolean) {
@@ -361,4 +361,3 @@ class Messaging : Feature("Messaging") {
return (future.get() as? List<*>)?.map { Snapchatter(it) } ?: return emptyList()
}
}

View File

@@ -1,16 +1,55 @@
package me.eternal.purrfectsnap.core.features.impl.spying
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.ConcurrentHashMap
class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH) {
private val displayedMessageQueue = CopyOnWriteArraySet<Long>()
private val snapInteractionQueue = CopyOnWriteArraySet<Long>()
private val ruleSnapshotCache = ConcurrentHashMap<String, Pair<Long, Set<MessagingRuleType>>>()
private fun getTargetId(conversationId: String): String {
return context.database.getDMOtherParticipant(conversationId) ?: conversationId
}
private fun getRuleSnapshot(conversationId: String): Set<MessagingRuleType> {
val targetId = getTargetId(conversationId)
val now = System.currentTimeMillis()
ruleSnapshotCache[targetId]?.takeIf { now - it.first < 1_000L }?.let { return it.second }
return context.bridgeClient.getRules(targetId).toSet().also { rules ->
ruleSnapshotCache[targetId] = now to rules
}
}
private fun isRuleActive(
conversationId: String,
ruleType: MessagingRuleType
): Boolean {
val ruleState = context.config.rules.getRuleState(ruleType) ?: return false
val enabled = ruleType in getRuleSnapshot(conversationId)
return if (ruleState == RuleState.BLACKLIST) !enabled else enabled
}
fun canUseChatStealth(conversationId: String): Boolean {
return isRuleActive(conversationId, MessagingRuleType.STEALTH) ||
isRuleActive(conversationId, MessagingRuleType.CHAT_STEALTH)
}
fun canUseSnapStealth(conversationId: String): Boolean {
return isRuleActive(conversationId, MessagingRuleType.STEALTH) ||
isRuleActive(conversationId, MessagingRuleType.SNAP_STEALTH)
}
fun isAnyStealthEnabled(conversationId: String): Boolean {
return canUseChatStealth(conversationId) || canUseSnapStealth(conversationId)
}
fun addDisplayedMessageException(clientMessageId: Long) {
displayedMessageQueue.add(clientMessageId)
@@ -22,12 +61,10 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT
override fun init() {
val isConversationInStealthMode: (SnapUUID) -> Boolean = { canUseRule(it.toString()) }
arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String ->
context.classCache.conversationManager.hook(methodName, HookStage.BEFORE) { param ->
if (displayedMessageQueue.removeIf { param.arg<Long>(1) == it }) return@hook
if (isConversationInStealthMode(SnapUUID(param.arg(0)))) {
if (canUseChatStealth(SnapUUID(param.arg(0)).toString())) {
param.setResult(null)
}
}
@@ -35,7 +72,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)) {
if (canUseSnapStealth(event.conversationId.toString())) {
event.canceled = true
}
}

View File

@@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
@@ -31,7 +30,7 @@ class StealthModeIndicator : Feature("StealthModeIndicator") {
private fun requestUpdate(conversationId: String) {
fetchJob?.cancel()
fetchJob = context.coroutineScope.launch {
val isStealth = stealthMode.canUseRule(conversationId)
val isStealth = stealthMode.isAnyStealthEnabled(conversationId)
withContext(Dispatchers.Main) {
listener(isStealth)
}
@@ -52,9 +51,9 @@ class StealthModeIndicator : Feature("StealthModeIndicator") {
if (!context.config.userInterface.stealthModeIndicator.get()) return
onNextActivityCreate {
stealthMode.addStateListener { conversationId, state ->
stealthMode.addStateListener { conversationId, _ ->
runCatching {
listeners[conversationId]?.invoke(stealthMode.getRuleState()?.let { if (it == RuleState.BLACKLIST) !state else state } ?: state)
listeners[conversationId]?.invoke(stealthMode.isAnyStealthEnabled(conversationId))
}.onFailure {
context.log.error("Failed to update stealth mode indicator", it)
}
@@ -100,4 +99,4 @@ class StealthModeIndicator : Feature("StealthModeIndicator") {
}
}
}
}
}