diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AndroidDialogCustom.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AndroidDialogCustom.kt index efaa1976..daf71ff2 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AndroidDialogCustom.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AndroidDialogCustom.kt @@ -466,22 +466,26 @@ private class DialogWrapper( this.onDismissRequest = onDismissRequest this.properties = properties setLayoutDirection(layoutDirection) - if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) { + val dialogWindow = window + val canUpdateWindowLayout = dialogWindow?.decorView?.let { decorView -> + isShowing && decorView.isAttachedToWindow && decorView.windowToken != null + } == true + if (canUpdateWindowLayout && properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) { // Undo fixed size in internalOnLayout, which would suppress size changes when // usePlatformDefaultWidth is true. - window?.setLayout( + dialogWindow.setLayout( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT ) } dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (canUpdateWindowLayout && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { @OptIn(ExperimentalComposeUiApi::class) if (properties.decorFitsSystemWindows) { - window?.setSoftInputMode(defaultSoftInputMode) + dialogWindow?.setSoftInputMode(defaultSoftInputMode) } else { @Suppress("DEPRECATION") - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + dialogWindow?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) } } } diff --git a/build.gradle.kts b/build.gradle.kts index 041b7df6..08ac65ef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,8 @@ tasks.register("getVersion") { } // You can still set these for legacy use by submodules or scripts: -rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.6").get()) -rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("322").get().toInt()) +rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.8").get()) +rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("324").get().toInt()) rootProject.ext.set("applicationId", "me.eternal.purrfectsnap") // buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate. // Include version code so each release has a different hash; use random for uniqueness within same version. diff --git a/changelogs-stable.txt b/changelogs-stable.txt index 87022d42..b2457893 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -1,3 +1,18 @@ +## v1.6.8 +- Fix: Many improvements to the Performance Mode feature(Max, turned on by Default), changes are pretty noticeable: faster loading of chats, long group messages optimizations, many snapmap optimizations +- Fix: Crash issues for some devices +- Fix: Block Ads +- Fix: Snapchat automatically restarting if kept idle +- Fix: Feed Flickering issue for some devices +- Fix: Implemented Dual-Thread Sync Architecture with a 1,000 depth that proactively scans the database for missed history, thus resolving the "failed to open..." and "Tap to load" snaps before marking them, clearing historical gaps automatically(tq to Kaladin) +- New: Implemented Screen Guard where Auto Open Engine now detects screen state and stops all notification work while the device is locked, thus reducing excessive battery drain and heat spike during background processing(tq to Kaladin) +- Fix: Disk read/write frequency increased from 1s to 5 minutes, to eliminate constant background disk friction, thus reducing overheating of the device while processing(tq to Kaladin) +- Fix: Slid chat switching delays from 2.0s down to 40ms, thus achieving near-instant processing of 1000+ snap bursts(tq to Kaladin) +- Fix: Removed "Auto Open pause while Gaming" toggle(tq to Kaladin) +- Fix: Reworked "Snap Pre-Fetch" toggle to the global Messaging settings page(tq to Kaladin) +- Fix: Refactored Auto Open engine notification cards to declutter it and removed redundant stats(tq to Kaladin) +- Fix: Corrected speed labels in the Auto Open notification to show "Idle" when monitoring and accurate notions (Full Speed/Throttled) when active(tq to Kaladin) + ## v1.6.6 - Fix: Persistent auto open snap disabled notification(tq to Kaladin) - Fix: Crash issues for some devices diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt index 166663a1..276a7510 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt @@ -168,11 +168,10 @@ class BridgeClient( Log.d("BridgeClient", "service is dead, restarting") val canLoad = connect { Log.e("BridgeClient", "connection failed", it) - context.softRestartApp() } if (canLoad != true) { Log.e("BridgeClient", "failed to reconnect to service, result=$canLoad") - context.softRestartApp() + return@runBlocking } } } @@ -188,9 +187,6 @@ class BridgeClient( block() }.getOrElse { Log.e("BridgeClient", "service call failed", it) - if (it is DeadObjectException) { - context.softRestartApp() - } throw it } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 53bd9661..0981f20a 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -80,6 +80,7 @@ class FeatureManager( MessageLogger(), ConvertMessageLocally(), SnapchatPlus(), + AdBlockFix(), DisableMetrics(), EndpointsBlocker(), PreventMessageSending(), diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt index b00ed3d8..715fa180 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt @@ -109,6 +109,10 @@ class ConfigurationOverride : Feature("Configuration Override") { { true }) overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() }, { true }) + overrideProperty("ENABLE_MESSAGE_WINDOW_MANAGER", { context.config.global.performanceMode.profile.getNullable() != null }, + { true }) + overrideProperty("ENABLE_SIMPLE_CONVERSATION_RESET", { context.config.global.performanceMode.profile.getNullable() == "max" }, + { false }) overrideProperty("PREVIEW_PRELOAD_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null }, { true }) overrideProperty("BUFFERED_VIDEO_RECORDING_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null }, @@ -120,27 +124,32 @@ class ConfigurationOverride : Feature("Configuration Override") { arrayOf( "FEATURE_PRELOADER", - "USER_STORY_PRELOAD", - "STARTUP_LENS_ACTIVATOR", - "LENSES_PREVIEW_ACTIVATOR", - "THUMBNAIL_PRESENTER_ACTIVATOR", - "SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR", "SERVER_PREFETCH", "SERVER_PREFETCH_WITH_COF", "DISCOVER_FEED_PERFORMANCE", - "DISCOVER_FEED_STORY_PREFETCH", - "DISCOVER_FEED_THUMBNAILS", "LOGIN_PRELOAD", "PREFETCH_REPO_SUBSCRIBE_ON_CPU", "COMPUTE_FEED_CACHE_WITH_TTL", "COMPUTE_FEED_NETWORK_WITH_CACHE", "OPERA_WARMUP", - "REFACTORED_WITH_WARMUP_LENS", "SHOW_PREFETCH", ).forEach { key -> overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() != null }, { true }) } + arrayOf( + "USER_STORY_PRELOAD", + "STARTUP_LENS_ACTIVATOR", + "LENSES_PREVIEW_ACTIVATOR", + "THUMBNAIL_PRESENTER_ACTIVATOR", + "SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR", + "DISCOVER_FEED_STORY_PREFETCH", + "DISCOVER_FEED_THUMBNAILS", + "REFACTORED_WITH_WARMUP_LENS", + ).forEach { key -> + overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() == "max" }, { false }) + } + overrideProperty("LOAD_LATENCY_TRACKER_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null }, { false }) overrideProperty("ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null }, diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/AdBlockFix.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/AdBlockFix.kt new file mode 100644 index 00000000..c630f242 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/AdBlockFix.kt @@ -0,0 +1,254 @@ +package me.eternal.purrfectsnap.core.features.impl.global + +import android.os.SystemClock +import android.view.View +import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent +import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.ui.hideViewCompletely +import me.eternal.purrfectsnap.core.ui.dispatchSyntheticTap +import me.eternal.purrfectsnap.core.util.dataBuilder +import me.eternal.purrfectsnap.core.util.hook.HookStage +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.wrapper.impl.SnapUUID +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap +import me.eternal.purrfectsnap.mapper.impl.CallbackMapper +import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper +import java.util.ArrayList +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +class AdBlockFix : Feature("AdBlockFix") { + private val adConversationIds = Collections.newSetFromMap(ConcurrentHashMap()) + + @Volatile + private var lastAutoSkippedOperaFingerprint: String? = null + + @Volatile + private var lastAutoSkippedOperaAt = 0L + + override fun init() { + if (!context.config.global.blockAds.get()) return + + hookFeedEntryTracking() + hookMessagingFeedCallbacks() + hookChatFeedRowSuppression() + hookOperaAutoSkip() + } + + private fun hookFeedEntryTracking() { + findClass("com.snapchat.client.messaging.FeedEntry").hookConstructor(HookStage.AFTER) { param -> + val feedEntry = param.thisObject() + val conversationId = feedEntry.getObjectFieldOrNull("mConversationId")?.let(::SnapUUID)?.toString() + ?: return@hookConstructor + + if (isCampaignFeedEntry(feedEntry) || isChatAdShareFeedEntry(feedEntry)) { + adConversationIds.add(conversationId) + } + } + } + + private fun hookMessagingFeedCallbacks() { + context.mappings.useMapper(CallbackMapper::class) { + classLoader = context.androidContext.classLoader + val callbackMap = callbacks.getAsMap().orEmpty() + val hookedCallbacks = mutableSetOf() + + fun hookOnce( + 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") + } + } + + callbackMap.entries.forEach { (callbackName, callbackClassName) -> + val className = callbackClassName ?: return@forEach + when { + callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> { + hookOnce(className, "onFetchAndSyncFeedComplete") { param -> + val deletedEntries = param.argNullable>(2) + filterCampaignFeed(param.arg(0), deletedEntries) + if (deletedEntries?.isNotEmpty() == true) { + param.setArg(4, true) + } + } + } + + callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> { + hookOnce(className, "onSyncFeedComplete") { param -> + filterCampaignFeed(param.arg(0), param.argNullable(2)) + } + } + + callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> { + hookOnce(className, "onFetchFeedComplete") { param -> + filterCampaignFeed(param.arg(0)) + } + } + + callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> { + hookOnce(className, "onFetchFeedEntriesComplete") { param -> + filterCampaignFeed(param.arg(0)) + } + } + + callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> { + hookOnce(className, "onQueryFeedComplete") { param -> + filterCampaignFeed(param.arg(0)) + } + } + + callbackName == "FeedManagerDelegate" -> { + hookOnce(className, "onFeedEntriesUpdated") { param -> + filterCampaignFeed(param.arg(0)) + } + hookOnce(className, "onInternalSyncFeed") { param -> + filterCampaignFeed(param.arg(0)) + } + } + } + } + } + } + + private fun hookChatFeedRowSuppression() { + context.event.subscribe(BindViewEvent::class) { event -> + val modelDump = event.prevModel.toString() + event.friendFeedItem { conversationId -> + if (adConversationIds.contains(conversationId) || isChatAdShareModel(modelDump)) { + hideBoundChatFeedRow(event.view) + } + } + } + } + + private fun hideBoundChatFeedRow(view: View) { + view.hideViewCompletely() + (view.parent as? View)?.hideViewCompletely() + (view.parent?.parent as? View)?.hideViewCompletely() + } + + private fun hookOperaAutoSkip() { + onNextActivityCreate { + context.mappings.useMapper(OperaPageViewControllerMapper::class) { + arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> + val resolvedMethod = methodName.get() ?: return@forEach + classReference.get()?.hook(resolvedMethod, HookStage.AFTER) { param -> + val viewState = runCatching { + param.thisObject().getObjectField(viewStateField.get()!!)?.toString() + }.getOrNull() ?: return@hook + if (viewState != "FULLY_DISPLAYED") return@hook + + val layerList = runCatching { + param.thisObject().getObjectField(layerListField.get()!!) as? ArrayList<*> + }.getOrNull() ?: return@hook + val paramMap = runCatching { + layerList.map { Layer(it).paramMap }.firstOrNull() + }.getOrNull() ?: return@hook + + if (!isSpotlightCommercialPage(paramMap)) return@hook + + val fingerprint = buildOperaFingerprint(paramMap) + val now = SystemClock.elapsedRealtime() + if (fingerprint == lastAutoSkippedOperaFingerprint && now - lastAutoSkippedOperaAt < 1_500L) { + return@hook + } + lastAutoSkippedOperaFingerprint = fingerprint + lastAutoSkippedOperaAt = now + + runOnUiThread { + context.mainActivity?.window?.decorView?.postDelayed({ + context.mainActivity?.window?.decorView?.let { decorView -> + val x = decorView.width * 0.88f + val y = decorView.height * 0.5f + decorView.dispatchSyntheticTap(x, y) + } + }, 70L) + } + } + } + } + } + } + + private fun filterCampaignFeed(entries: ArrayList, deletedEntries: ArrayList? = null) { + entries.removeIf { feedEntry -> + if (!isCampaignFeedEntry(feedEntry)) return@removeIf false + val conversationIdInstance = feedEntry.getObjectFieldOrNull("mConversationId") ?: return@removeIf true + deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance)) + true + } + } + + private fun createDeletedFeedEntry(conversationIdInstance: Any) = + findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder { + from("mFeedEntryIdentifier") { + set("mConversationId", conversationIdInstance) + } + set("mReason", "AD_CAMPAIGN_COMPLETE") + }!! + + private fun isCampaignFeedEntry(feedEntry: Any?): Boolean { + if (feedEntry == null) return false + if (feedEntry.getObjectFieldOrNull("mConversationSubType")?.toString() == "CAMPAIGN") { + return true + } + return feedEntry.getObjectFieldOrNull("mConversationSubTypeMetadata") + ?.getObjectFieldOrNull("mCampaignMetadata") != null + } + + private fun isChatAdShareFeedEntry(feedEntry: Any): Boolean { + val interactionDump = feedEntry.getObjectFieldOrNull("mInteractionInfo")?.toString().orEmpty() + val displayDump = feedEntry.getObjectFieldOrNull("mDisplayInfo")?.toString().orEmpty() + val combined = "$interactionDump $displayDump" + return isChatAdShareModel(combined) + } + + private fun isChatAdShareModel(modelDump: String): Boolean { + if (modelDump.isBlank()) return false + return modelDump.contains("CHAT_AD_SHARE") || + modelDump.contains("AD_SHARE") || + modelDump.contains("ChatAd") || + modelDump.contains("chat_ad_share") || + modelDump.contains("chat_sponsored_snap") || + modelDump.contains("CommonAttachmentViewModel") || + modelDump.contains("visibilityFeedbackURL") || + modelDump.contains("pageLoadPingURL") + } + + private fun isSpotlightCommercialPage(paramMap: ParamMap): Boolean { + val snapSource = paramMap["SNAP_SOURCE"]?.toString() + if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY") { + return false + } + + val adProductType = paramMap["ad_product_type"]?.toString() + if (!adProductType.isNullOrBlank() && adProductType != "UNKNOWN" && adProductType != "null") { + return true + } + + val pageDump = paramMap.toString().uppercase() + return pageDump.contains("COMMERCIAL") || pageDump.contains("PROMOTED_STORY") + } + + private fun buildOperaFingerprint(paramMap: ParamMap): String { + val storyId = paramMap["STORY_ID"]?.toString() + val snapId = paramMap["SNAP_ID"]?.toString() + ?: paramMap["snap_id"]?.toString() + val index = paramMap["snap_index_in_story"]?.toString() + ?: paramMap["SNAP_POSITION_IN_STORY"]?.toString() + return listOfNotNull(storyId, snapId, index, paramMap["ad_product_type"]?.toString()).joinToString("|") + } +} 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 947b3999..2b7f1343 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 @@ -12,6 +12,7 @@ import android.hardware.camera2.CameraCharacteristics.Key import android.hardware.camera2.CameraManager import android.media.Image import android.media.ImageReader +import android.os.Build import android.util.Range import me.eternal.purrfectsnap.core.features.Feature import me.eternal.purrfectsnap.core.util.hook.HookStage @@ -28,6 +29,9 @@ class CameraTweaks : Feature("Camera Tweaks") { @SuppressLint("MissingPermission", "DiscouragedApi") override fun init() { val config = context.config.camera + val skipUnstableStillCaptureTweaks = Build.MANUFACTURER.equals("samsung", ignoreCase = true) || + Build.HARDWARE.contains("exynos", ignoreCase = true) || + Build.BRAND.equals("samsung", ignoreCase = true) // Toggle A: Audio & Video Optimizations (Bitrates) if (config.audioVideoOptimizations.get()) { @@ -44,7 +48,7 @@ class CameraTweaks : Feature("Camera Tweaks") { } // Toggle B: Camera Optimizations (Hardware ISP - UNSTABLE) - if (config.cameraOptimizations.get()) { + if (config.cameraOptimizations.get() && !skipUnstableStillCaptureTweaks) { CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param -> val key = param.arg>(0) when (key) { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt index 8fa00135..8265bd4b 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt @@ -3,37 +3,55 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks import android.animation.ValueAnimator import android.app.Activity import android.app.Dialog +import android.content.Context import android.database.Cursor import android.database.MatrixCursor import android.database.sqlite.SQLiteDatabase -import android.hardware.camera2.CaptureRequest import android.media.MediaRecorder -import android.os.Build -import android.transition.Transition import android.os.HandlerThread import android.os.Process import android.util.Base64 import android.util.Range import android.view.View -import android.view.ViewPropertyAnimator -import android.view.WindowManager -import android.view.animation.Animation import android.widget.OverScroller import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import java.io.File import java.lang.Thread +import java.lang.reflect.Method +import java.util.LinkedHashMap +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.ThreadPoolExecutor +import com.google.gson.reflect.TypeToken import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent +import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID +import me.eternal.purrfectsnap.mapper.impl.CallbackMapper import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod 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.setObjectField import okhttp3.Dispatcher class PerformanceMode : Feature("Performance Mode") { + companion object { + private const val CHAT_FEED_CACHE_MAX_ROWS = 400 + private const val CHAT_FEED_CACHE_MAX_BLOB_BYTES = 512 + private const val CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS = 15_000L + private const val MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS = 64 + private const val MESSAGE_WINDOW_STATE_MAX_AGE_MS = 7L * 24L * 60L * 60L * 1000L + private const val SNAP_PREFETCH_GROUP_MESSAGES = 48 + private const val SNAP_PREFETCH_DM_MESSAGES = 24 + private const val REOPEN_WARMUP_GROUP_MESSAGES = 160 + private const val REOPEN_WARMUP_DM_MESSAGES = 96 + } + private data class SnapshotCell( val type: Int, val stringValue: String? = null, @@ -47,6 +65,15 @@ class PerformanceMode : Feature("Performance Mode") { val rows: List>, ) + private data class MessageWindowState( + val conversationId: String, + val currentSize: Int, + val oldestOrderKey: Long?, + val newestOrderKey: Long?, + val updatedAt: Long, + val isGroup: Boolean, + ) + override fun init() { val profile = context.config.global.performanceMode.profile.getNullable() ?: return val isMaxProfile = profile == "max" @@ -56,6 +83,7 @@ class PerformanceMode : Feature("Performance Mode") { Process.THREAD_PRIORITY_MORE_FAVORABLE } val minimumFrameRate = if (isMaxProfile) 60 else 45 + val minimumRecordingFrameRate = if (isMaxProfile) 30 else 24 val durationScale = if (isMaxProfile) 0.35f else 0.55f val recyclerViewCacheSize = if (isMaxProfile) 64 else 32 val maxRequests = if (isMaxProfile) 192 else 96 @@ -63,11 +91,16 @@ class PerformanceMode : Feature("Performance Mode") { val minimumCoreThreads = if (isMaxProfile) 16 else 8 val prefetchItemCount = if (isMaxProfile) 24 else 12 val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L - val maxScrollDurationMs = if (isMaxProfile) 120 else 180 + val maxScrollDurationMs = if (isMaxProfile) 72 else 180 val preferredRefreshRate = if (isMaxProfile) 120f else 90f + val snapMapTransitionDurationMs = if (isMaxProfile) 0L else 24L + val snapMapCameraDurationMs = if (isMaxProfile) 16L else 64L + val snapMapMoveDurationMs = if (isMaxProfile) 8L else 40L + val snapMapPrefetchZoomDelta = if (isMaxProfile) 6 else 3 + val preferredJavaThreadPriority = if (isMaxProfile) Thread.NORM_PRIORITY + 2 else Thread.NORM_PRIORITY + 1 context.log.info( - "Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate", + "Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, minRecordingFps=$minimumRecordingFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate, snapMapTransitionMs=$snapMapTransitionDurationMs, snapMapCameraMs=$snapMapCameraDurationMs, snapMapMoveMs=$snapMapMoveDurationMs, snapMapPrefetchZoomDelta=$snapMapPrefetchZoomDelta, javaThreadPriority=$preferredJavaThreadPriority", "PerformanceMode" ) @@ -91,38 +124,58 @@ class PerformanceMode : Feature("Performance Mode") { val executorLog = firstHitLogger("ThreadPoolExecutor.constructor") val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor") val animatorLog = firstHitLogger("ValueAnimator.getDurationScale") - val animatorDurationLog = firstHitLogger("ValueAnimator.setDuration") - val viewAnimatorDurationLog = firstHitLogger("ViewPropertyAnimator.setDuration") - val transitionDurationLog = firstHitLogger("Transition.setDuration") - val animationDurationLog = firstHitLogger("Animation.setDuration") val recyclerCtorLog = firstHitLogger("RecyclerView.constructor") val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter") val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager") val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase") val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase") val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate") - val captureRequestLog = firstHitLogger("CaptureRequest.Builder.set") - val sustainedModeLog = firstHitLogger("Window.setSustainedPerformanceMode") - val refreshRateLog = firstHitLogger("Activity.preferredRefreshRate") val overScrollerLog = firstHitLogger("OverScroller.startScroll") - val chatFeedCacheServeLog = firstHitLogger("ChatFeed.cacheServe") - val chatFeedCacheRefreshLog = firstHitLogger("ChatFeed.cacheRefresh") val mapDialogLog = firstHitLogger("Dialog.show") val mapViewLog = firstHitLogger("MapView.constructor") val mapboxNetworkBlockLog = firstHitLogger("SnapMap.telemetryBlock") val mapCameraAnimLog = firstHitLogger("SnapMap.mapAnimatorDuration") val mapThreadLog = firstHitLogger("SnapMap.mapThread") val mapRendererFpsLog = firstHitLogger("SnapMap.mapRendererFps") - + val mapTransitionLog = firstHitLogger("SnapMap.transitionOptions") + val mapMoveLog = firstHitLogger("SnapMap.moveDuration") fun isPerformanceSensitiveThread(name: String?): Boolean { val normalizedName = name?.lowercase() ?: return false - return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any { + return listOf("codec", "transcod", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any { normalizedName.contains(it) } } + fun clampPositiveDuration(durationMs: Long, maxDurationMs: Long): Long { + if (durationMs <= 0L) return durationMs + return durationMs.coerceAtMost(maxDurationMs) + } + val performanceCacheDir = File(context.androidContext.filesDir, "performance_mode_cache").apply { mkdirs() } val chatFeedSnapshotFile = File(performanceCacheDir, "chat_feed_snapshot.json") + val lastChatFeedSnapshotWrite = AtomicLong(0L) + val chatFeedSnapshotServedThisProcess = AtomicBoolean(false) + + val windowStatePrefs = context.androidContext.getSharedPreferences("purrfectsnap_perf_message_windows", Context.MODE_PRIVATE) + val messageWindowStates = runCatching { + val raw = windowStatePrefs.getString("states", null).orEmpty() + if (raw.isBlank()) { + LinkedHashMap() + } else { + context.gson.fromJson>( + raw, + object : TypeToken>() {}.type + ) ?: LinkedHashMap() + } + }.getOrElse { LinkedHashMap() } + + fun persistMessageWindowStates() { + runCatching { + windowStatePrefs.edit().putString("states", context.gson.toJson(messageWindowStates)).apply() + }.onFailure { + context.log.error("Failed to persist message window states", it, "PerformanceMode") + } + } fun isChatFeedQuery(sql: String): Boolean { val normalized = sql.uppercase() @@ -144,7 +197,9 @@ class PerformanceMode : Feature("Performance Mode") { Cursor.FIELD_TYPE_STRING -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index)) Cursor.FIELD_TYPE_BLOB -> SnapshotCell( Cursor.FIELD_TYPE_BLOB, - blobValue = Base64.encodeToString(cursor.getBlob(index), Base64.NO_WRAP) + blobValue = cursor.getBlob(index) + ?.takeIf { it.size <= CHAT_FEED_CACHE_MAX_BLOB_BYTES } + ?.let { Base64.encodeToString(it, Base64.NO_WRAP) } ) else -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index)) } @@ -154,9 +209,11 @@ class PerformanceMode : Feature("Performance Mode") { val columns = cursor.columnNames.toList() val rows = mutableListOf>() if (cursor.moveToFirst()) { + var rowCount = 0 do { rows += columns.indices.map { index -> cursorCell(cursor, index) } - } while (cursor.moveToNext()) + rowCount++ + } while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext()) } return CursorSnapshot(columns, rows) } @@ -201,12 +258,6 @@ class PerformanceMode : Feature("Performance Mode") { context.log.info("Invalidated chat feed snapshot after friends mutation sync", "PerformanceMode") } } - if (url.contains("messaging") || url.contains("conversation") || url.contains("feed")) { - if (chatFeedSnapshotFile.exists()) { - chatFeedSnapshotFile.delete() - context.log.info("Invalidated chat feed snapshot after messaging/feed network activity", "PerformanceMode") - } - } if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) { event.canceled = true mapboxNetworkBlockLog("url=$url") @@ -237,7 +288,7 @@ class PerformanceMode : Feature("Performance Mode") { val thread = param.thisObject() if (!isPerformanceSensitiveThread(thread.name)) return@hook runCatching { - thread.priority = Thread.MAX_PRIORITY + thread.priority = preferredJavaThreadPriority } threadStartLog("name=${thread.name} priority=${thread.priority}") if ((thread.name ?: "").contains("map", ignoreCase = true) || (thread.name ?: "").contains("mapbox", ignoreCase = true)) { @@ -272,51 +323,10 @@ class PerformanceMode : Feature("Performance Mode") { animatorLog("durationScale=$durationScale") } - ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param -> - val original = param.arg(0) - val updated = original.coerceAtMost(maxAnimationDurationMs) - if (updated != original) { - param.setArg(0, updated) - } - animatorDurationLog("requested=$original applied=${param.arg(0)}") - val thisObject = param.nullableThisObject()?.javaClass?.name ?: "" - if (thisObject.contains("map", ignoreCase = true)) { - mapCameraAnimLog("owner=$thisObject requested=$original applied=${param.arg(0)}") - } - } - - ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param -> - val original = param.arg(0) - val updated = original.coerceAtMost(maxAnimationDurationMs) - if (updated != original) { - param.setArg(0, updated) - } - viewAnimatorDurationLog("requested=$original applied=${param.arg(0)}") - } - - Transition::class.java.hook("setDuration", HookStage.BEFORE) { param -> - val original = param.arg(0) - val updated = original.coerceAtMost(maxAnimationDurationMs) - if (updated != original) { - param.setArg(0, updated) - } - transitionDurationLog("requested=$original applied=${param.arg(0)}") - } - - Animation::class.java.hook("setDuration", HookStage.BEFORE) { param -> - val original = param.arg(0) - val updated = original.coerceAtMost(maxAnimationDurationMs) - if (updated != original) { - param.setArg(0, updated) - } - animationDurationLog("requested=$original applied=${param.arg(0)}") - } - RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param -> val recyclerView = param.thisObject() recyclerView.setItemViewCacheSize(recyclerViewCacheSize) recyclerView.overScrollMode = View.OVER_SCROLL_NEVER - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) if (isMaxProfile) { recyclerView.itemAnimator = null } @@ -326,7 +336,6 @@ class PerformanceMode : Feature("Performance Mode") { RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param -> val recyclerView = param.thisObject() recyclerView.setItemViewCacheSize(recyclerViewCacheSize) - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) if (isMaxProfile) { recyclerView.itemAnimator = null } @@ -346,7 +355,6 @@ class PerformanceMode : Feature("Performance Mode") { layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS } } - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount") } @@ -375,8 +383,11 @@ class PerformanceMode : Feature("Performance Mode") { MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param -> val currentRate = param.arg(0) - if (currentRate < minimumFrameRate) { - param.setArg(0, minimumFrameRate) + val applied = currentRate + .coerceAtLeast(minimumRecordingFrameRate) + .coerceAtMost(if (isMaxProfile) 60 else 45) + if (applied != currentRate) { + param.setArg(0, applied) } mediaRecorderLog("requested=$currentRate applied=${param.arg(0)}") } @@ -401,27 +412,9 @@ class PerformanceMode : Feature("Performance Mode") { } } - CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param -> - val key = param.arg>(0) - captureRequestLog("key=${key.name} value=${param.argNullable(1)}") - } - fun applyActivityPerformanceTuning(activity: Activity) { runCatching { - activity.window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null) - val display = activity.display - val targetRefreshRate = display?.supportedModes?.maxByOrNull { it.refreshRate }?.refreshRate - ?.coerceAtLeast(preferredRefreshRate) ?: preferredRefreshRate - activity.window.attributes = activity.window.attributes.apply { - this.preferredRefreshRate = targetRefreshRate - } - refreshRateLog("activity=${activity::class.java.name} refreshRate=$targetRefreshRate") - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) { - runCatching { - activity.window.setSustainedPerformanceMode(true) - sustainedModeLog("activity=${activity::class.java.name}") - } + activity.window.setWindowAnimations(0) } } @@ -434,10 +427,6 @@ class PerformanceMode : Feature("Performance Mode") { val window = dialog.window ?: return@hook runCatching { window.setWindowAnimations(0) - window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null) - window.attributes = window.attributes.apply { - flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED - } if (dialog::class.java.name.contains("map", ignoreCase = true) || dialog::class.java.name.contains("snap", ignoreCase = true)) { mapDialogLog("class=${dialog::class.java.name}") } @@ -447,7 +436,6 @@ class PerformanceMode : Feature("Performance Mode") { runCatching { findClass("com.mapbox.mapboxsdk.maps.MapView").hookConstructor(HookStage.AFTER) { param -> val mapView = param.nullableThisObject() as? View ?: return@hookConstructor - mapView.setLayerType(View.LAYER_TYPE_HARDWARE, null) mapView.overScrollMode = View.OVER_SCROLL_NEVER mapViewLog("class=${mapView::class.java.name}") } @@ -464,14 +452,165 @@ class PerformanceMode : Feature("Performance Mode") { } } + runCatching { + val nativeMapViewClass = findClass("com.mapbox.mapboxsdk.maps.NativeMapView") + val transitionOptionsClass = findClass("com.mapbox.mapboxsdk.style.layers.TransitionOptions") + val transitionOptionsCtor = transitionOptionsClass.getDeclaredConstructor(Long::class.javaPrimitiveType, Long::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).apply { + isAccessible = true + } + + fun findNativeMapMethod(name: String, predicate: (Method) -> Boolean): Method? { + return nativeMapViewClass.findRestrictedMethod { method -> + method.name == name && predicate(method) + }?.apply { + isAccessible = true + } + } + + val nativeCancelTransitions = findNativeMapMethod("nativeCancelTransitions") { it.parameterCount == 0 } + val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType } + val nativeSetPrefetchZoomDelta = findNativeMapMethod("nativeSetPrefetchZoomDelta") { it.parameterCount == 1 && it.parameterTypes[0] == Int::class.javaPrimitiveType } + val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType } + val nativeSetTransitionDuration = findNativeMapMethod("nativeSetTransitionDuration") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType } + val nativeSetTransitionOptions = findNativeMapMethod("nativeSetTransitionOptions") { it.parameterCount == 1 && it.parameterTypes[0].name == transitionOptionsClass.name } + + nativeMapViewClass.hookConstructor(HookStage.AFTER) { param -> + val nativeMapView = param.thisObject() + runCatching { + nativeSetPrefetchTiles?.invoke(nativeMapView, true) + nativeSetPrefetchZoomDelta?.invoke(nativeMapView, snapMapPrefetchZoomDelta) + nativeSetTransitionDelay?.invoke(nativeMapView, 0L) + nativeSetTransitionDuration?.invoke(nativeMapView, snapMapTransitionDurationMs) + nativeSetTransitionOptions?.invoke( + nativeMapView, + transitionOptionsCtor.newInstance(snapMapTransitionDurationMs, 0L, false) + ) + nativeCancelTransitions?.invoke(nativeMapView) + mapTransitionLog("transitionMs=$snapMapTransitionDurationMs prefetchZoomDelta=$snapMapPrefetchZoomDelta placementTransitions=false") + } + } + + nativeMapViewClass.findRestrictedMethod { method -> + method.name == "g" && + method.parameterCount == 6 && + method.parameterTypes.last() == Long::class.javaPrimitiveType + }?.hook(HookStage.BEFORE) { param -> + val original = param.arg(5) + val applied = clampPositiveDuration(original, snapMapCameraDurationMs) + if (applied != original) { + param.setArg(5, applied) + } + runCatching { nativeCancelTransitions?.invoke(param.thisObject()) } + mapCameraAnimLog("requested=$original applied=${param.arg(5)}") + } + + nativeMapViewClass.findRestrictedMethod { method -> + method.name == "v" && + method.parameterCount == 3 && + method.parameterTypes[0] == Double::class.javaPrimitiveType && + method.parameterTypes[1] == Double::class.javaPrimitiveType && + method.parameterTypes[2] == Long::class.javaPrimitiveType + }?.hook(HookStage.BEFORE) { param -> + val original = param.arg(2) + val applied = clampPositiveDuration(original, snapMapMoveDurationMs) + if (applied != original) { + param.setArg(2, applied) + } + runCatching { nativeCancelTransitions?.invoke(param.thisObject()) } + mapMoveLog("requested=$original applied=${param.arg(2)}") + } + }.onFailure { + context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode") + } + + runCatching { + findClass("com.snapchat.client.messaging.MessageWindowManager\$CppProxy").hook("initWindow", HookStage.BEFORE) { param -> + if (!isMaxProfile) return@hook + + val conversationId = runCatching { + SnapUUID(param.arg(0)).toString() + }.getOrNull()?.takeIf { it.isNotBlank() } ?: return@hook + + val initParams = param.arg(1) + val conversationType = context.database.getConversationType(conversationId) ?: return@hook + val isGroup = conversationType == 1 + val savedState = synchronized(messageWindowStates) { + messageWindowStates[conversationId] + ?.takeIf { System.currentTimeMillis() - it.updatedAt <= MESSAGE_WINDOW_STATE_MAX_AGE_MS } + } + + val enumConstants = initParams.getObjectField("mStartingType")?.javaClass?.enumConstants ?: return@hook + if (savedState != null) { + val restoredMaxSize = if (savedState.isGroup) { + savedState.currentSize.coerceAtLeast(220).coerceAtMost(520) + } else { + savedState.currentSize.coerceAtLeast(140).coerceAtMost(320) + } + val restoredForward = (savedState.currentSize + if (savedState.isGroup) 24 else 16).coerceAtMost(restoredMaxSize) + val restoredBack = if (savedState.isGroup) 180 else 120 + initParams.setObjectField("mStartingType", enumConstants.firstOrNull { it.toString() == "MESSAGE" } ?: return@hook) + initParams.setObjectField("mStartingOrderKey", savedState.oldestOrderKey ?: savedState.newestOrderKey) + initParams.setObjectField("mMaxSize", restoredMaxSize) + initParams.setObjectField("mNumMessagesForward", restoredForward) + initParams.setObjectField("mNumMessagesBack", restoredBack) + + val warmupAmount = if (savedState.isGroup) REOPEN_WARMUP_GROUP_MESSAGES else REOPEN_WARMUP_DM_MESSAGES + val oldestKey = savedState.oldestOrderKey + if (oldestKey != null) { + context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated( + conversationId = conversationId, + lastMessageId = oldestKey, + amount = warmupAmount, + onSuccess = {}, + onError = {} + ) + } + } + } + }.onFailure { + context.log.error("Failed to install saved message window restore hooks", it, "PerformanceMode") + } + + context.mappings.useMapper(CallbackMapper::class) { + callbacks.getClass("MessageWindowManagerDelegate")?.hook("onWindowUpdated", HookStage.AFTER) { param -> + if (!isMaxProfile) return@hook + + val conversationId = runCatching { SnapUUID(param.arg(0)).toString() }.getOrNull() ?: return@hook + val update = param.arg(2) + val pagination = update.getObjectField("mPagination") ?: return@hook + val currentSize = pagination.getObjectField("mCurrentSize") as? Int ?: return@hook + val oldestOrderKey = pagination.getObjectField("mOldestOrderKey") as? Long + val newestOrderKey = pagination.getObjectField("mNewestOrderKey") as? Long + val conversationType = context.database.getConversationType(conversationId) ?: 0 + val isGroup = conversationType == 1 + + synchronized(messageWindowStates) { + messageWindowStates[conversationId] = MessageWindowState( + conversationId = conversationId, + currentSize = currentSize.coerceAtMost(if (isGroup) 420 else 260), + oldestOrderKey = oldestOrderKey, + newestOrderKey = newestOrderKey, + updatedAt = System.currentTimeMillis(), + isGroup = isGroup + ) + while (messageWindowStates.size > MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS) { + val eldestKey = messageWindowStates.entries.minByOrNull { it.value.updatedAt }?.key ?: break + messageWindowStates.remove(eldestKey) + } + persistMessageWindowStates() + } + } + } + runCatching { findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.BEFORE) { param -> if (!isMaxProfile) return@hook val sql = param.argNullable(1) ?: return@hook if (!isChatFeedQuery(sql)) return@hook + if (chatFeedSnapshotServedThisProcess.get()) return@hook readSnapshot(chatFeedSnapshotFile)?.let { snapshot -> param.setResult(snapshotToMatrixCursor(snapshot)) - chatFeedCacheServeLog("rows=${snapshot.rows.size} file=${chatFeedSnapshotFile.name}") + chatFeedSnapshotServedThisProcess.set(true) } } @@ -479,15 +618,18 @@ class PerformanceMode : Feature("Performance Mode") { if (!isMaxProfile) return@hook val sql = param.argNullable(1) ?: return@hook if (!isChatFeedQuery(sql)) return@hook + if (chatFeedSnapshotFile.exists()) return@hook val cursor = param.getResult() as? Cursor ?: return@hook + val now = System.currentTimeMillis() + if (now - lastChatFeedSnapshotWrite.get() < CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS) return@hook val snapshot = snapshotFromCursor(cursor) + if (snapshot.rows.isEmpty()) return@hook writeSnapshot(chatFeedSnapshotFile, snapshot) - param.setResult(snapshotToMatrixCursor(snapshot)) - runCatching { cursor.close() } - chatFeedCacheRefreshLog("rows=${snapshot.rows.size} file=${chatFeedSnapshotFile.name}") + lastChatFeedSnapshotWrite.set(now) } }.onFailure { context.log.error("Failed to install chat feed cache hooks", it, "PerformanceMode") } + } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/ConversationManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/ConversationManager.kt index a84886d6..83b71183 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/ConversationManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/ConversationManager.kt @@ -30,6 +30,7 @@ class ConversationManager( private val fetchConversationWithMessagesMethod by lazy { findMethodByName("fetchConversationWithMessages") } private val fetchMessageByServerId by lazy { findMethodByName("fetchMessageByServerId") } private val fetchMessagesByServerIds by lazy { findMethodByName("fetchMessagesByServerIds") } + private val fetchPrefetchableMessagesForConversationsMethod by lazy { findMethodByName("fetchPrefetchableMessagesForConversations") } private val displayedMessagesMethod by lazy { findMethodByName("displayedMessages") } private val fetchMessage by lazy { findMethodByName("fetchMessage") } private val clearConversation by lazy { findMethodByName("clearConversation") } @@ -163,6 +164,37 @@ class ConversationManager( ) } + fun fetchPrefetchableMessagesForConversations( + conversationIds: List, + strategyName: String, + messagesPerConversation: Int, + onSuccess: (List) -> Unit = {}, + onError: (error: String) -> Unit = {} + ) { + val prefetchRequestClass = fetchPrefetchableMessagesForConversationsMethod.parameterTypes.firstOrNull { + it.name == "com.snapchat.client.messaging.PrefetchRequest" + } ?: error("PrefetchRequest parameter type not found") + val strategyClass = context.androidContext.classLoader.loadClass("com.snapchat.client.messaging.PrefetchStrategy") + val strategy = strategyClass.enumConstants?.firstOrNull { it.toString() == strategyName } + ?: error("PrefetchStrategy $strategyName not found") + val prefetchRequest = prefetchRequestClass + .getConstructor(strategyClass, Int::class.javaPrimitiveType) + .newInstance(strategy, messagesPerConversation) + + fetchPrefetchableMessagesForConversationsMethod.invoke( + instanceNonNull(), + conversationIds.map { it.toSnapUUID().instanceNonNull() }.toCollection(ArrayList()), + prefetchRequest, + CallbackBuilder(getCallbackClass("FetchMessagesCallback")) + .override("onFetchMessagesComplete") { param -> + onSuccess(param.arg>(0).map { Message(it) }) + } + .override("onError") { + onError(it.arg(0).toString()) + }.build() + ) + } + fun clearConversation(conversationId: String, onSuccess: () -> Unit, onError: (error: String) -> Unit) { val callback = CallbackBuilder(getCallbackClass("Callback")) .override("onSuccess") { onSuccess() } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/SnapUUID.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/SnapUUID.kt index 4de42119..d9cc7518 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/SnapUUID.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/SnapUUID.kt @@ -19,6 +19,14 @@ fun UUID.toBytes(): ByteArray = class SnapUUID( private val obj: Any? ) : AbstractWrapper(obj) { + private fun extractUuidBytesFromObject(any: Any): ByteArray? { + runCatching { any.getObjectField("mId") as? ByteArray }.getOrNull()?.let { return it } + runCatching { any.javaClass.getMethod("getId").invoke(any) as? ByteArray }.getOrNull()?.let { return it } + runCatching { any.javaClass.getMethod("getUuid").invoke(any) as? ByteArray }.getOrNull()?.let { return it } + runCatching { any.javaClass.getMethod("uuid").invoke(any) as? ByteArray }.getOrNull()?.let { return it } + return null + } + private val uuidBytes by lazy { when { obj is String -> { @@ -38,6 +46,10 @@ class SnapUUID( any.getObjectField("mId") as ByteArray } } + obj is Any -> { + extractUuidBytesFromObject(obj) + ?: runCatching { UUID.fromString(obj.toString()).toBytes() }.getOrElse { ByteArray(16) } + } else -> ByteArray(16) } } diff --git a/gradle.properties b/gradle.properties index 88e96fd7..839e6e23 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,8 +7,8 @@ org.gradle.configuration-cache=true org.gradle.configuration-cache.problems=warn nativeAbis=arm64-v8a -APP_VERSION_NAME=1.6.6 -APP_VERSION_CODE=322 +APP_VERSION_NAME=1.6.8 +APP_VERSION_CODE=324 debug_build_hash=18fe2a814d0e2eb5 psIntegrityPinnedSha256= EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c diff --git a/valdi/src/main/ts/imports.ts b/valdi/src/main/ts/imports.ts index 4f30a165..a190661b 100644 --- a/valdi/src/main/ts/imports.ts +++ b/valdi/src/main/ts/imports.ts @@ -5,10 +5,16 @@ declare var _runtimeName: string; export const runtimeName = _runtimeName; let remoteImports: any = null; -try { - remoteImports = require(_runtimeName + "_core/DeviceBridge")?.[_getImportsFunctionName]?.(); -} catch { - remoteImports = null; +for (const moduleName of ["DeviceBridge", "Device"]) { + try { + const imports = require(_runtimeName + "_core/" + moduleName)?.[_getImportsFunctionName]?.(); + if (imports != null) { + remoteImports = imports; + break; + } + } catch { + // Some Snapchat builds expose DeviceBridge, others only Device. + } } function callRemoteFunction(method: string, ...args: any[]): any | null {