diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt index 84a54472..2c548c47 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -83,16 +83,37 @@ class BridgeService : Service() { callback.syncGroup(id) } } ?: run { + if (updateOnly) { + when (scope) { + SocialScope.FRIEND -> database.deleteFriend(id) + SocialScope.GROUP -> database.deleteGroup(id) + } + return + } remoteSideContext.log.warn("Failed to sync $scope $id") return } when (scope) { SocialScope.FRIEND -> { - toParcelable(syncedObject)?.let { database.syncFriend(it) } + toParcelable(syncedObject)?.let { database.syncFriend(it) } ?: run { + if (updateOnly) { + database.deleteFriend(id) + return + } + remoteSideContext.log.warn("Failed to sync $scope $id") + return + } } SocialScope.GROUP -> { - toParcelable(syncedObject)?.let { database.syncGroupInfo(it) } + toParcelable(syncedObject)?.let { database.syncGroupInfo(it) } ?: run { + if (updateOnly) { + database.deleteGroup(id) + return + } + remoteSideContext.log.warn("Failed to sync $scope $id") + return + } } } }.onFailure { @@ -218,10 +239,10 @@ class BridgeService : Service() { friends: List ) { remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends") - remoteSideContext.database.receiveMessagingDataCallback( - friends.mapNotNull { toParcelable(it) }, - groups.mapNotNull { toParcelable(it) } - ) + val parsedFriends = friends.mapNotNull { toParcelable(it) } + val parsedGroups = groups.mapNotNull { toParcelable(it) } + remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups) + remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups) } override fun getScopeNotes(id: String): String? { 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 773768b8..94a1d4a5 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt @@ -86,6 +86,76 @@ fun AppDatabase.syncFriend(friend: MessagingFriendInfo) { } } +fun AppDatabase.replaceMessagingData( + friends: List, + groups: List +) { + executeAsync { + database.beginTransaction() + try { + val friendIds = friends.map { it.userId }.toSet() + val groupIds = groups.map { it.conversationId }.toSet() + + getFriends().forEach { friend -> + if (friend.userId !in friendIds) { + database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId)) + database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId)) + database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId)) + } + } + + getGroups().forEach { group -> + if (group.conversationId !in groupIds) { + database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId)) + database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId)) + } + } + + friends.forEach { friend -> + database.execSQL( + "INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)", + arrayOf( + friend.userId, + friend.dmConversationId, + friend.displayName, + friend.mutableUsername, + friend.bitmojiId, + friend.selfieId + ) + ) + + friend.streaks?.takeIf { it.length > 0 }?.also { + val streaks = getFriendStreaks(friend.userId) + database.execSQL( + "INSERT OR REPLACE INTO streaks (id, notify, expirationTimestamp, length) VALUES (?, ?, ?, ?)", + arrayOf( + friend.userId, + streaks?.notify != false, + it.expirationTimestamp, + it.length + ) + ) + } ?: database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId)) + } + + groups.forEach { group -> + database.execSQL( + "INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)", + arrayOf( + group.conversationId, + group.name, + group.participantsCount + ) + ) + } + + database.setTransactionSuccessful() + } finally { + database.endTransaction() + } + } +} + fun AppDatabase.getRules(targetUuid: String): List { return database.rawQuery( "SELECT type FROM rules WHERE targetUuid = ?", arrayOf(targetUuid) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt index 874349e1..0ab8a31d 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt @@ -37,11 +37,13 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavBackStackEntry import kotlinx.coroutines.launch import me.eternal.purrfectsnap.R +import me.eternal.purrfectsnap.common.ReceiversConfig import me.eternal.purrfectsnap.common.data.MessagingFriendInfo import me.eternal.purrfectsnap.common.data.MessagingGroupInfo import me.eternal.purrfectsnap.common.data.SocialScope import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie +import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper import me.eternal.purrfectsnap.storage.* import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette @@ -58,6 +60,16 @@ class SocialRootSection : Routes.Route() { } } + private fun requestLatestSnapshot() { + runCatching { + context.androidContext.sendBroadcast( + SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {} + ) + }.onFailure { + context.log.error("Failed to request latest social snapshot", it) + } + } + @Composable private fun ScopeList( scope: SocialScope, @@ -196,7 +208,17 @@ class SocialRootSection : Routes.Route() { var searchActive by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { + context.database.receiveMessagingDataCallback = { friends, groups -> + friendList = friends + groupList = groups + } updateScopeLists() + requestLatestSnapshot() + } + DisposableEffect(Unit) { + onDispose { + context.database.receiveMessagingDataCallback = { _, _ -> } + } } val normalizedQuery = remember(searchQuery) { searchQuery.trim() } val filteredFriends = remember(friendList, normalizedQuery) { diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/bridge/wrapper/MappingsWrapper.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/bridge/wrapper/MappingsWrapper.kt index 28353821..f814db37 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/bridge/wrapper/MappingsWrapper.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/bridge/wrapper/MappingsWrapper.kt @@ -54,6 +54,9 @@ class MappingsWrapper( if (!exists()) { throw Exception("Mappings file does not exist") } + mappers.values.forEach { mapper -> + mapper.classLoader = context.classLoader + } val mappingsObject = JsonParser.parseString(readBytes().toString(Charsets.UTF_8)).asJsonObject.also { mappingUniqueHash = it["unique_hash"].asLong } @@ -61,7 +64,6 @@ class MappingsWrapper( mappingsObject.entrySet().forEach { (key, value) -> mappers.values.firstOrNull { it.mapperName == key }?.let { mapper -> mapper.readFromJson(value.asJsonObject) - mapper.classLoader = context.classLoader } } isMappingsLoaded = true @@ -74,6 +76,9 @@ class MappingsWrapper( fileHandleManager.value.getFileHandle(FileHandleScope.INTERNAL.key, InternalFileHandleType.NATIVE_SIG_CACHE.key).delete() val classMapper = ClassMapper(*mappers.values.toTypedArray()) + mappers.values.forEach { mapper -> + mapper.classLoader = context.classLoader + } runCatching { classMapper.loadApk(getSnapchatPackageInfo()?.applicationInfo?.sourceDir ?: throw Exception("Failed to get APK")) @@ -88,6 +93,8 @@ class MappingsWrapper( writeBytes(result.toString().toByteArray()) } + loadCached() + return classMapper.getWarns() } @@ -99,4 +106,4 @@ class MappingsWrapper( AbstractLogger.directError("Mapper ${type.simpleName} is not registered", Throwable()) } } -} \ No newline at end of file +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt index 933727fc..8f14a4e0 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt @@ -17,6 +17,7 @@ import me.eternal.purrfectsnap.bridge.SyncCallback import me.eternal.purrfectsnap.common.Constants import me.eternal.purrfectsnap.common.ReceiversConfig import me.eternal.purrfectsnap.common.action.EnumAction +import me.eternal.purrfectsnap.common.data.FriendLinkType import me.eternal.purrfectsnap.common.bridge.FileHandleScope import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType import me.eternal.purrfectsnap.common.bridge.toWrapper @@ -454,21 +455,29 @@ class PurrfectSnap { ) } - val friends = feedEntries.filter { it.conversationType == 0 }.mapNotNull { - val friendUserId = it.friendUserId ?: it.participants?.firstOrNull { it != appContext.database.myUserId } - ?: return@mapNotNull null - val friend = appContext.database.getFriendInfo(friendUserId) ?: return@mapNotNull null - - MessagingFriendInfo( - friendUserId, - it.key, - friend.displayName, - friend.mutableUsername ?: friend.usernameForSorting!!, - friend.bitmojiAvatarId, - friend.bitmojiSelfieId, - streaks = null - ) - } + val friends = appContext.database.getAllFriends() + .asSequence() + .filter { friend -> + friend.userId != null && when (FriendLinkType.fromValue(friend.friendLinkType)) { + FriendLinkType.DELETED, + FriendLinkType.BLOCKED, + FriendLinkType.SUGGESTED -> false + else -> true + } + } + .mapNotNull { friend -> + val userId = friend.userId ?: return@mapNotNull null + MessagingFriendInfo( + userId = userId, + dmConversationId = appContext.database.getDMConversationId(userId), + displayName = friend.displayName, + mutableUsername = friend.mutableUsername ?: friend.usernameForSorting ?: return@mapNotNull null, + bitmojiId = friend.bitmojiAvatarId, + selfieId = friend.bitmojiSelfieId, + streaks = null + ) + } + .toList() appContext.bridgeClient.passGroupsAndFriends(groups, friends) } @@ -485,9 +494,15 @@ class PurrfectSnap { appContext.bridgeClient.sync(object : SyncCallback.Stub() { override fun syncFriend(uuid: String): String? { return appContext.database.getFriendInfo(uuid)?.let { + if (FriendLinkType.fromValue(it.friendLinkType) in setOf( + FriendLinkType.DELETED, + FriendLinkType.BLOCKED, + FriendLinkType.SUGGESTED + ) + ) return@let null MessagingFriendInfo( userId = it.userId!!, - dmConversationId = null, + dmConversationId = appContext.database.getDMConversationId(it.userId!!), displayName = it.displayName, mutableUsername = it.mutableUsername!!, bitmojiId = it.bitmojiAvatarId, diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/MessagingRuleFeature.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/MessagingRuleFeature.kt index bfb72516..0b88a7d8 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/MessagingRuleFeature.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/MessagingRuleFeature.kt @@ -27,7 +27,9 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp ).contains(ruleType) && getRuleState() != null fun canUseRule(conversationId: String): Boolean { - if (getRuleState() == null) return false + if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) { + return false + } val state = getState(conversationId) if (context.config.rules.getRuleState(ruleType) == RuleState.BLACKLIST) { return !state diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/HideTypingIndicator.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/HideTypingIndicator.kt index 0c31e095..4614dbc0 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/HideTypingIndicator.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/HideTypingIndicator.kt @@ -4,13 +4,31 @@ import me.eternal.purrfectsnap.common.data.MessagingRuleType 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 class HideTypingIndicator : MessagingRuleFeature("Hide Typing Indicator", MessagingRuleType.HIDE_TYPING_INDICATOR) { private val messaging: Messaging by lazy { context.feature(Messaging::class) } + + private fun shouldHideTypingIndicator(conversationId: String?): Boolean { + return conversationId?.let { canUseRule(it) } ?: false + } + + private fun currentConversationId(): String? { + return messaging.openedConversationUUID?.toString() + } override fun init() { context.classCache.presenceSession.hook("processTypingActivity", HookStage.BEFORE, { - messaging.openedConversationUUID?.toString()?.let { canUseRule(it) } ?: false + shouldHideTypingIndicator(currentConversationId()) + }) { + it.setResult(null) + } + + context.classCache.conversationManager.hook("sendTypingNotification", HookStage.BEFORE, { param -> + val conversationId = currentConversationId() ?: param.argNullable(0)?.let { + runCatching { SnapUUID(it).toString() }.getOrNull() + } + shouldHideTypingIndicator(conversationId) }) { it.setResult(null) } 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 ed96c276..ab195879 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 @@ -17,6 +17,7 @@ import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull import me.eternal.purrfectsnap.core.wrapper.impl.* import me.eternal.purrfectsnap.mapper.impl.CallbackMapper import me.eternal.purrfectsnap.mapper.impl.FriendsFeedEventDispatcherMapper +import me.eternal.purrfectsnap.mapper.impl.PlatformPresenceActionWrapperMapper import java.util.UUID import java.util.concurrent.Future @@ -52,8 +53,37 @@ class Messaging : Feature("Messaging") { lastFocusedConversationType = -1 } + private fun currentConversationId(): String? = openedConversationUUID?.toString() + + private fun shouldHideBitmojiPresence(stealthMode: StealthMode): Boolean { + return context.config.messaging.hideBitmojiPresence.get() || + currentConversationId()?.let { stealthMode.canUseRule(it) } == true + } + + private fun shouldHideTyping(stealthMode: StealthMode, hideTypingIndicator: HideTypingIndicator): Boolean { + return context.config.messaging.hideTypingNotifications.get() || + currentConversationId()?.let { stealthMode.canUseRule(it) || hideTypingIndicator.canUseRule(it) } == true + } + + private fun shouldHidePeek(stealthMode: StealthMode): Boolean { + return context.config.messaging.hidePeekAPeek.get() || + currentConversationId()?.let { stealthMode.canUseRule(it) } == true + } + + private fun clearField(instance: Any, typeNamePart: String, shouldClear: Boolean) { + if (!shouldClear) return + instance.javaClass.declaredFields.forEach { field -> + if (field.type.name.contains(typeNamePart)) { + field.isAccessible = true + field.set(instance, null) + } + } + } + override fun init() { val stealthMode = context.feature(StealthMode::class) + val hideTypingIndicator = context.feature(HideTypingIndicator::class) + context.classCache.conversationManager.hookConstructor(HookStage.BEFORE) { param -> synchronized(conversationManagerReadyListeners) { conversationManager = ConversationManager(context, param.thisObject()) @@ -98,20 +128,100 @@ class Messaging : Feature("Messaging") { } defer { - arrayOf("activate", "deactivate").forEach { hook -> + arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook -> context.classCache.presenceSession.hook(hook, HookStage.BEFORE, { - val conversationId = openedConversationUUID?.toString() ?: return@hook false - context.config.messaging.hideBitmojiPresence.get() || stealthMode.canUseRule(conversationId) + shouldHideBitmojiPresence(stealthMode) }) { it.setResult(null) } } context.classCache.presenceSession.hook("startPeeking", HookStage.BEFORE, { - val conversationId = openedConversationUUID?.toString() ?: return@hook false - context.config.messaging.hidePeekAPeek.get() || stealthMode.canUseRule(conversationId) + shouldHidePeek(stealthMode) }) { it.setResult(null) } + context.classCache.conversationManager.hook("sendTypingNotification", HookStage.BEFORE, { + shouldHideTyping(stealthMode, hideTypingIndicator) + }) { + it.setResult(null) + } + + context.mappings.useMapper(PlatformPresenceActionWrapperMapper::class) { + classLoader = context.androidContext.classLoader + if (classReference.getAsClass() == null) { + runCatching { context.mappings.refresh() }.onFailure { + context.log.error("Failed to refresh mappings for PlatformPresenceActionWrapper", it) + } + } + + classReference.getAsClass()?.let { wrapperClass -> + val bitmojiMethodNames = mutableSetOf() + val typingMethodNames = mutableSetOf() + val peekingMethodNames = mutableSetOf() + + wrapperClass.methods.forEach { method -> + val parameterTypes = method.parameterTypes + + if (parameterTypes.any { parameterType -> + listOf( + "PlatformChatVisibleAction", + "PlatformChatHiddenAction", + "PlatformViewingChatMediaAction", + "PlatformUsingReplyCameraAction" + ).any { parameterType.name.contains(it) } + }) { + bitmojiMethodNames.add(method.name) + } + + if (parameterTypes.any { parameterType -> + parameterType.name.contains("PlatformTypingAction") + }) { + typingMethodNames.add(method.name) + } + + if (parameterTypes.any { parameterType -> + parameterType.name.contains("PlatformStartPeekingAction") + }) { + peekingMethodNames.add(method.name) + } + } + + bitmojiMethodNames.forEach { methodName -> + wrapperClass.hook(methodName, HookStage.BEFORE, { + shouldHideBitmojiPresence(stealthMode) + }) { + it.setResult(null) + } + } + + typingMethodNames.forEach { methodName -> + wrapperClass.hook(methodName, HookStage.BEFORE, { + shouldHideTyping(stealthMode, hideTypingIndicator) + }) { + it.setResult(null) + } + } + + peekingMethodNames.forEach { methodName -> + wrapperClass.hook(methodName, HookStage.BEFORE, { + shouldHidePeek(stealthMode) + }) { + it.setResult(null) + } + } + + wrapperClass.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject() + clearField(instance, "PlatformChatVisibleAction", shouldHideBitmojiPresence(stealthMode)) + clearField(instance, "PlatformChatHiddenAction", shouldHideBitmojiPresence(stealthMode)) + clearField(instance, "PlatformViewingChatMediaAction", shouldHideBitmojiPresence(stealthMode)) + clearField(instance, "PlatformUsingReplyCameraAction", shouldHideBitmojiPresence(stealthMode)) + clearField(instance, "PlatformTypingAction", shouldHideTyping(stealthMode, hideTypingIndicator)) + clearField(instance, "PlatformStartPeekingAction", shouldHidePeek(stealthMode)) + } + } + } + //get last opened snap for media downloader context.event.subscribe(OnSnapInteractionEvent::class) { event -> openedConversationUUID = event.conversationId 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 5effa879..f80f00b9 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 @@ -22,7 +22,6 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT override fun init() { - if (getRuleState() == null) return val isConversationInStealthMode: (SnapUUID) -> Boolean = { canUseRule(it.toString()) } arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String -> diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/CameraTweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/CameraTweaks.kt index 7032b64a..49683aa6 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/CameraTweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/CameraTweaks.kt @@ -27,16 +27,18 @@ class CameraTweaks : Feature("Camera Tweaks") { override fun init() { val config = context.config.camera - config.startupDefaultCamera.getNullable()?.let { defaultCamera -> - context.database.setCameraType(if (defaultCamera == "back") "BACK_FACING" else "FRONT_FACING") - } - val frontCameraId by lazy { runCatching { context.androidContext.getSystemService(CameraManager::class.java).run { cameraIdList.firstOrNull { getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT } } }.getOrNull() } + val backCameraId by lazy { + runCatching { context.androidContext.getSystemService(CameraManager::class.java).run { + cameraIdList.firstOrNull { getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK } + } }.getOrNull() + } + if (config.disableCameras.get().isNotEmpty() && frontCameraId != null) { ContextWrapper::class.java.hook("checkPermission", HookStage.BEFORE) { param -> val permission = param.arg(0) @@ -47,11 +49,24 @@ class CameraTweaks : Feature("Camera Tweaks") { } var isLastCameraFront = false + var startupCameraApplied = false CameraManager::class.java.hook("openCamera", HookStage.BEFORE) { param -> val cameraManager = param.thisObject() as? CameraManager ?: return@hook - val cameraId = param.arg(0) + var cameraId = param.arg(0) val disabledCameras = config.disableCameras.get() + val startupDefaultCamera = config.startupDefaultCamera.getNullable() + + if (startupDefaultCamera != null && !startupCameraApplied) { + val preferredCameraId = if (startupDefaultCamera == "back") backCameraId else frontCameraId + if (preferredCameraId != null) { + if (preferredCameraId != cameraId) { + param.setArg(0, preferredCameraId) + cameraId = preferredCameraId + } + startupCameraApplied = true + } + } if (disabledCameras.size >= 2) { param.setResult(null) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/HideFriendFeedEntry.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/HideFriendFeedEntry.kt index c5139c96..b9f47a7c 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/HideFriendFeedEntry.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/HideFriendFeedEntry.kt @@ -31,41 +31,97 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType } } + private fun hookCallbackMethod( + hookedCallbacks: MutableSet, + callbackClassName: String, + methodName: String, + block: (param: me.eternal.purrfectsnap.core.util.hook.HookAdapter) -> Unit + ) { + val hookKey = "$callbackClassName#$methodName" + if (!hookedCallbacks.add(hookKey)) return + runCatching { + findClass(callbackClassName).hook(methodName, HookStage.BEFORE) { param -> + block(param) + } + }.onFailure { + context.log.warn("Failed to hook $methodName on $callbackClassName") + } + } + override fun init() { if (!context.config.userInterface.hideFriendFeedEntry.get()) return context.mappings.useMapper(CallbackMapper::class) { - arrayOf( - "FetchAndSyncFeedWithConversationIdsCallback" to "onFetchAndSyncFeedComplete", - "FetchFeedCallback" to "onFetchFeedComplete", - "FetchFeedEntriesCallback" to "onFetchFeedEntriesComplete", - "QueryFeedCallback" to "onQueryFeedComplete", - "FeedManagerDelegate" to "onFeedEntriesUpdated", - "FeedManagerDelegate" to "onInternalSyncFeed", - ).forEach { (callbackName, methodName) -> - findClass(callbacks.get()!![callbackName] ?: return@forEach).hook(methodName, HookStage.BEFORE) { param -> - filterFriendFeed(param.arg(0)) + classLoader = context.androidContext.classLoader + val hasFetchAndSyncCallback = callbacks.getAsMap()?.entries?.any { + it.key.startsWith("FetchAndSyncFeed") && it.key.endsWith("Callback") + } == true + if (callbacks.getClass("SyncFeedCallback") == null || !hasFetchAndSyncCallback) { + runCatching { context.mappings.refresh() }.onFailure { + context.log.error("Failed to refresh mappings for HideFriendFeedEntry callbacks", it) + } + classLoader = context.androidContext.classLoader + } + val callbackMap = callbacks.getAsMap().orEmpty() + val hookedCallbacks = mutableSetOf() + + callbackMap.entries.forEach { (callbackName, callbackClassName) -> + when { + callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> { + hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchAndSyncFeedComplete") { param -> + val deletedConversations: ArrayList = param.arg(2) + filterFriendFeed(param.arg(0), deletedConversations) + + if (deletedConversations.any { + val uuid = SnapUUID(it.getObjectField("mFeedEntryIdentifier")?.getObjectField("mConversationId")).toString() + context.database.getFeedEntryByConversationId(uuid) != null + }) { + param.setArg(4, true) + } + } + } + + callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> { + hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onSyncFeedComplete") { param -> + filterFriendFeed(param.arg(0), param.argNullable(2)) + } + } + + callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> { + hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedComplete") { param -> + filterFriendFeed(param.arg(0)) + } + } + + callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> { + hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedEntriesComplete") { param -> + filterFriendFeed(param.arg(0)) + } + } + + callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> { + hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onQueryFeedComplete") { param -> + filterFriendFeed(param.arg(0)) + } + } + + callbackName == "FeedManagerDelegate" -> { + hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFeedEntriesUpdated") { param -> + filterFriendFeed(param.arg(0)) + } + hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onInternalSyncFeed") { param -> + filterFriendFeed(param.arg(0)) + } + } } } - callbacks.getAsMap()?.entries?.firstOrNull { it.key.startsWith("FetchAndSyncFeed") && it.key.endsWith("Callback") } - ?.value - ?.let { findClass(it) } - ?.hook("onFetchAndSyncFeedComplete", HookStage.BEFORE) { param -> - val deletedConversations: ArrayList = param.arg(2) - filterFriendFeed(param.arg(0), deletedConversations) - - if (deletedConversations.any { - val uuid = SnapUUID(it.getObjectField("mFeedEntryIdentifier")?.getObjectField("mConversationId")).toString() - context.database.getFeedEntryByConversationId(uuid) != null - }) { - param.setArg(4, true) - } - } ?: context.log.warn("Failed to hook FetchAndSyncFeedCallback") - callbacks.getClass("SyncFeedCallback") - ?.hook("onSyncFeedComplete", HookStage.BEFORE) { param -> - filterFriendFeed(param.arg(0), param.arg(2)) - } ?: context.log.warn("Failed to hook SyncFeedCallback") + if (callbackMap.entries.none { it.key.startsWith("FetchAndSyncFeed") && it.key.endsWith("Callback") }) { + context.log.warn("Failed to hook FetchAndSyncFeedCallback") + } + if (callbackMap.entries.none { it.key.contains("SyncFeed") && it.key.endsWith("Callback") }) { + context.log.warn("Failed to hook SyncFeedCallback") + } } } diff --git a/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/ClassMapper.kt b/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/ClassMapper.kt index 9f2eedc5..ee76145d 100644 --- a/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/ClassMapper.kt +++ b/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/ClassMapper.kt @@ -41,6 +41,7 @@ class ClassMapper( FoldingLayoutMapper(), PlatformClientAttestationMapper(), ChatMediaDrawerMapper(), + PlatformPresenceActionWrapperMapper(), ) } diff --git a/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/impl/PlatformPresenceActionWrapperMapper.kt b/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/impl/PlatformPresenceActionWrapperMapper.kt new file mode 100644 index 00000000..296049d6 --- /dev/null +++ b/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/impl/PlatformPresenceActionWrapperMapper.kt @@ -0,0 +1,18 @@ +package me.eternal.purrfectsnap.mapper.impl + +import me.eternal.purrfectsnap.mapper.AbstractClassMapper +import me.eternal.purrfectsnap.mapper.ext.getClassName + +class PlatformPresenceActionWrapperMapper : AbstractClassMapper("PlatformPresenceActionWrapper") { + val classReference = classReference("class") + + init { + mapper { + classes.firstOrNull { classDef -> + classDef.fields.any { field -> + field.type == "Lcom/snap/presence/PlatformChatVisibleAction;" + } + }?.let { classReference.set(it.getClassName()) } + } + } +}