diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index 4f45efe4..fc0f1d29 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -1,6 +1,5 @@ package me.eternal.purrfectsnap.core.features.impl.experiments -import android.app.ActivityManager import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -15,7 +14,6 @@ import android.os.Build import android.os.PowerManager import androidx.core.content.edit import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.Mutex @@ -42,7 +40,7 @@ import kotlin.random.Random /** * AutoOpenSnaps: High-performance engine with real-time diagnostics. - * Optimized for 20+ snaps/s with accurate stats and background resilience. + * Optimized for background resilience and industrial stability. */ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) { companion object { @@ -99,7 +97,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val now = System.currentTimeMillis(); val window = 5000L synchronized(snapTimestamps) { snapTimestamps.removeIf { now - it > window } - // Smoother calculation for high-frequency bursts return if (snapTimestamps.isEmpty()) 0.0 else (snapTimestamps.size.toDouble() / (window / 1000.0)) } } @@ -113,7 +110,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A restorePersistence() createNotificationChannels() - // NATIVE HOOKS: Ensuring Snapchat never sees the app as "In Background" if ((autoOpenConfig.allowRunningInBackground as PropertyValue).get()) { runCatching { findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply { @@ -121,7 +117,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val state = param.arg(0).toString() if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null) } - // INDUSTRIAL FIX: Restoring the universal v1.6.8 background wake-up hook hookConstructor(HookStage.AFTER) { param -> methods.firstOrNull { it.name == "appStateChanged" }?.let { method -> val enumClass = method.parameterTypes[0] @@ -158,7 +153,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A continue } - // SPEED OPTIMIZATION: Instant switch (40ms) when stealth is off val isSafe = (autoOpenConfig.safeProcessing as PropertyValue).get() if (lastConversationId != null && lastConversationId != item.conversationId) { delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue).get().toLong() else 40L) @@ -198,7 +192,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A success = withContext(Dispatchers.IO) { performOpen(item) } if (success) { - // IMPORTANT: Item only removed after successful processing to ensure Stats sync synchronized(queuedSnaps) { queuedSnaps.remove(item) } sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() val duration = System.currentTimeMillis() - startTime @@ -305,7 +298,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun isWifiConnected(): Boolean { val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - return cm.getNetworkCapabilities(cm.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + + // RESILIENT WIFI CHECK: Iterates through all networks to find ANY WiFi transport (VPN aware) + return cm.allNetworks.any { network -> + cm.getNetworkCapabilities(network)?.let { caps -> + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + } == true + } } private fun isDeviceIdle(): Boolean = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode @@ -355,7 +355,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status") .setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY) - // ICON LOGIC: Pause, Monitoring (Sync), or Active (Play) val iconRes = when { isPaused.get() -> android.R.drawable.ic_media_pause !isWorking -> android.R.drawable.ic_popup_sync diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/PinConversations.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/PinConversations.kt index 03e946ca..b54971ee 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/PinConversations.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/PinConversations.kt @@ -7,47 +7,112 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.Hooker import me.eternal.purrfectsnap.core.util.hook.hook import me.eternal.purrfectsnap.core.util.hook.hookConstructor -import me.eternal.purrfectsnap.core.util.ktx.getObjectField +import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull import me.eternal.purrfectsnap.core.util.ktx.setObjectField import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID +import me.eternal.purrfectsnap.mapper.impl.CallbackMapper +import java.util.Collections class PinConversations : MessagingRuleFeature("PinConversations", MessagingRuleType.PIN_CONVERSATION) { + companion object { + // 3-year offset for persistent local conversation sorting + private const val PIN_OFFSET = 100000000000L + } + + private fun forcePinsInFeed(entries: ArrayList) { + val now = System.currentTimeMillis() + // Capture stable timestamp once to prevent jitter during the sweep + val stableTimestamp = now + PIN_OFFSET + + entries.forEach { entry -> + val conversationIdObject = entry.getObjectFieldOrNull("mConversationId") ?: return@forEach + runCatching { + val conversationUUID = SnapUUID(conversationIdObject) + if (getState(conversationUUID.toString())) { + // Apply identical timestamp lead to all pinned items + entry.setObjectField("mPinnedTimestampMs", stableTimestamp) + } else { + // Reset timestamp if it's currently a "Future" timestamp but shouldn't be pinned + val currentTs = entry.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L + if (currentTs > now + (PIN_OFFSET / 2)) { + entry.setObjectField("mPinnedTimestampMs", now) + } + } + } + } + + // Manual sort to ensure stable UI transition and prevent list jumping + runCatching { + Collections.sort(entries) { a, b -> + val tsA = a.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L + val tsB = b.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L + tsB.compareTo(tsA) + } + } + } + override fun init() { if (!context.config.messaging.unlimitedConversationPinning.get()) return + // Intercept native pinning requests and bypass server-side limits context.classCache.feedManager.hook("setPinnedConversationStatus", HookStage.BEFORE) { param -> val conversationUUID = SnapUUID(param.arg(0)) val isPinned = param.arg(1).toString() == "PINNED" setState(conversationUUID.toString(), isPinned) + + // Callback forcing to suppress "Can't pin conversation" errors for both PIN and UNPIN val callback = param.arg(2) mutableSetOf<() -> Unit>().apply { - addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onSuccess", HookStage.BEFORE) { + addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback, "onSuccess", HookStage.BEFORE) { forEach { it() } }) - addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onError", HookStage.BEFORE) { methodParam -> + addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback, "onError", HookStage.BEFORE) { methodParam -> methodParam.setResult(null) + // Manually trigger success to bypass server-side limit rejections callback::class.java.getDeclaredMethod("onSuccess").invoke(callback) }) } } - context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> - val instance = param.thisObject() - val conversationUUID = SnapUUID(instance.getObjectField("mConversationId")) - if (getState(conversationUUID.toString())) { - instance.setObjectField("mPinnedTimestampMs", 1L) + // Active feed sweep to ensure pinned conversations remain at the top + context.mappings.useMapper(CallbackMapper::class) { + val callbackMap = callbacks.getAsMap().orEmpty() + callbackMap.entries.forEach { (_, className) -> + val clazz = runCatching { findClass(className!!) }.getOrNull() ?: return@forEach + clazz.methods.forEach { method -> + if (method.name.startsWith("on") && method.name.endsWith("Complete") && method.parameterTypes.any { it == ArrayList::class.java }) { + clazz.hook(method.name, HookStage.BEFORE) { param -> + (param.args().firstOrNull { it is ArrayList<*> } as? ArrayList)?.let { forcePinsInFeed(it) } + } + } + } } } + // Apply pinning lead to newly created conversation objects + context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject() + val conversationIdObject = instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor + runCatching { + val conversationUUID = SnapUUID(conversationIdObject) + if (getState(conversationUUID.toString())) { + instance.setObjectField("mPinnedTimestampMs", System.currentTimeMillis() + PIN_OFFSET) + } + } + } + + // Apply pinning lead to newly created feed entry objects context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> val instance = param.thisObject() - val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor) - val isPinned = getState(conversationUUID.toString()) - if (isPinned) { - instance.setObjectField("mPinnedTimestampMs", 1L) + val conversationIdObject = instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor + runCatching { + val conversationUUID = SnapUUID(conversationIdObject) + if (getState(conversationUUID.toString())) { + instance.setObjectField("mPinnedTimestampMs", System.currentTimeMillis() + PIN_OFFSET) + } } } } override fun getRuleState() = RuleState.WHITELIST -} \ No newline at end of file +}