diff --git a/.gitignore b/.gitignore index c5462867..d828fa2f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +.vs diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt index 850fd814..020c4f21 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt @@ -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 { 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 (?, ?)", diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt index a002d0d6..0f4b85b2 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/ManageScope.kt @@ -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() ) diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json index 9da9df09..c1f595a7 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -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", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 4f9bedbe..3cbbacd6 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -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", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/MessagingCoreObjects.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/MessagingCoreObjects.kt index bb9f40c9..70c53ec5 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/MessagingCoreObjects.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/MessagingCoreObjects.kt @@ -49,6 +49,8 @@ enum class MessagingRuleType( val configNotices: Array = 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.normalizeStealthRules(): Set { + 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.withNormalizedRuleToggle( + ruleType: MessagingRuleType, + enabled: Boolean +): Set { + 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, diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/AutoMarkAsRead.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/AutoMarkAsRead.kt index b54fa38c..8bc66f3d 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/AutoMarkAsRead.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/AutoMarkAsRead.kt @@ -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) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/AutoSave.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/AutoSave.kt index 4e4556ab..fe234277 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/AutoSave.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/AutoSave.kt @@ -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) } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Messaging.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Messaging.kt index bac88cf5..846a06d3 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Messaging.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Messaging.kt @@ -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() } } - diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/StealthMode.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/StealthMode.kt index f80f00b9..405a4e61 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/StealthMode.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/spying/StealthMode.kt @@ -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() private val snapInteractionQueue = CopyOnWriteArraySet() + private val ruleSnapshotCache = ConcurrentHashMap>>() + + private fun getTargetId(conversationId: String): String { + return context.database.getDMOtherParticipant(conversationId) ?: conversationId + } + + private fun getRuleSnapshot(conversationId: String): Set { + 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(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 } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/StealthModeIndicator.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/StealthModeIndicator.kt index 828f8c23..067947ba 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/StealthModeIndicator.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/StealthModeIndicator.kt @@ -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") { } } } -} \ No newline at end of file +}