From 7dd9ada91d470236515f3946afc8f018ab8d7105 Mon Sep 17 00:00:00 2001 From: schrodingerspet Date: Sun, 12 Apr 2026 16:06:23 +0530 Subject: [PATCH] feat(performance-mode): fix max profile lifecycle/resume corruption --- .../common/config/ConfigContainer.kt | 5 +- .../purrfectsnap/common/config/ModConfig.kt | 18 +++ .../features/impl/tweaks/PerformanceMode.kt | 147 ++++++++++++++---- 3 files changed, 135 insertions(+), 35 deletions(-) diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigContainer.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigContainer.kt index 4848d287..aeb87e87 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigContainer.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigContainer.kt @@ -1,6 +1,7 @@ package me.eternal.purrfectsnap.common.config import android.content.Context +import com.google.gson.JsonNull import com.google.gson.JsonObject import me.eternal.purrfectsnap.common.logger.AbstractLogger import kotlin.reflect.KProperty @@ -79,7 +80,9 @@ open class ConfigContainer( properties.forEach { (propertyKey, propertyValue) -> if (!exportSensitiveData && propertyKey.params.flags.contains(ConfigFlag.SENSITIVE)) return@forEach if (!includeSavedLocations && propertyKey.dataType.type == DataProcessors.Type.MAP_COORDINATES) return@forEach - val serializedValue = propertyValue.getRaw()?.let { propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations) } + val serializedValue = propertyValue.getRaw()?.let { + propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations) + } ?: JsonNull.INSTANCE json.add(propertyKey.name, serializedValue) } return json diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ModConfig.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ModConfig.kt index f6578e90..3237a329 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ModConfig.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ModConfig.kt @@ -58,6 +58,23 @@ class ModConfig( val configObject = gson.fromJson(configFileContent.toString(Charsets.UTF_8), JsonObject::class.java) locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE config.fromJson(configObject) + migratePerformanceProfileIfMissing(configObject, config) + } + + private fun migratePerformanceProfileIfMissing(configObject: JsonObject, config: RootConfig) { + runCatching { + val performanceModeProperties = configObject + .getAsJsonObject("global") + ?.getAsJsonObject("properties") + ?.getAsJsonObject("performance_mode") + ?.getAsJsonObject("properties") + ?: return@runCatching + if (!performanceModeProperties.has("performance_profile")) { + config.global.performanceMode.profile.set(null) + } + }.onFailure { + AbstractLogger.directError("Failed to migrate performance profile", it) + } } fun exportToString( @@ -158,6 +175,7 @@ class ModConfig( val configObject = gson.fromJson(string, JsonObject::class.java) locale = configObject.get("_locale")?.asString ?: LocaleWrapper.DEFAULT_LOCALE root.fromJson(configObject) + migratePerformanceProfileIfMissing(configObject, root) writeConfig() // Return saved locations array if present (for caller to handle database import) 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 8265bd4b..71b43ff2 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 @@ -11,7 +11,6 @@ import android.media.MediaRecorder import android.os.HandlerThread import android.os.Process import android.util.Base64 -import android.util.Range import android.view.View import android.widget.OverScroller import androidx.recyclerview.widget.LinearLayoutManager @@ -21,7 +20,6 @@ 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 @@ -44,6 +42,8 @@ class PerformanceMode : Feature("Performance Mode") { 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 CHAT_FEED_CACHE_MAX_AGE_MS = 5L * 60L * 1000L + private const val CHAT_FEED_CACHE_SCHEMA_VERSION = 2 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 @@ -65,6 +65,13 @@ class PerformanceMode : Feature("Performance Mode") { val rows: List>, ) + private data class ChatFeedSnapshotCache( + val schemaVersion: Int, + val queryKey: String, + val createdAt: Long, + val snapshot: CursorSnapshot, + ) + private data class MessageWindowState( val conversationId: String, val currentSize: Int, @@ -156,6 +163,22 @@ class PerformanceMode : Feature("Performance Mode") { val lastChatFeedSnapshotWrite = AtomicLong(0L) val chatFeedSnapshotServedThisProcess = AtomicBoolean(false) + fun invalidateChatFeedSnapshot(reason: String) { + val deleted = runCatching { + if (!chatFeedSnapshotFile.exists()) return@runCatching false + chatFeedSnapshotFile.delete() + }.getOrDefault(false) + chatFeedSnapshotServedThisProcess.set(false) + if (deleted) { + context.log.info("Invalidated chat feed snapshot ($reason)", "PerformanceMode") + } + } + + Activity::class.java.hook("onResume", HookStage.AFTER) { + if (!isMaxProfile) return@hook + chatFeedSnapshotServedThisProcess.set(false) + } + val windowStatePrefs = context.androidContext.getSharedPreferences("purrfectsnap_perf_message_windows", Context.MODE_PRIVATE) val messageWindowStates = runCatching { val raw = windowStatePrefs.getString("states", null).orEmpty() @@ -177,16 +200,32 @@ class PerformanceMode : Feature("Performance Mode") { } } + val snapshotQueryWhitespaceRegex = Regex("\\s+") + fun buildChatFeedSnapshotQueryKey(sql: String): String { + return sql.lowercase() + .replace(snapshotQueryWhitespaceRegex, " ") + .trim() + } + fun isChatFeedQuery(sql: String): Boolean { - val normalized = sql.uppercase() - if (!normalized.startsWith("SELECT")) return false - val hitsFriendsFeedView = sql.contains("FriendsFeedView") - val hitsFeedEntry = sql.contains("feed_entry") && (sql.contains("last_updated_timestamp") || sql.contains("displayInteractionType") || sql.contains("streak_count")) - return (hitsFriendsFeedView || hitsFeedEntry) && - !normalized.contains("COUNT(") && - !normalized.contains("SELECT 0") && - !normalized.contains("WHERE KEY = ?") && - !normalized.contains("WHERE CLIENT_CONVERSATION_ID = ?") + val normalized = buildChatFeedSnapshotQueryKey(sql) + if (!normalized.startsWith("select ")) return false + + val isFriendsFeedViewQuery = + normalized.startsWith("select * from friendsfeedview ") && + normalized.contains(" order by _id ") && + normalized.contains(" limit ") + + val isFeedEntryQuery = + normalized.startsWith("select * from feed_entry ") && + normalized.contains(" order by last_updated_timestamp desc ") && + normalized.contains(" limit ") + + return (isFriendsFeedViewQuery || isFeedEntryQuery) && + !normalized.contains("count(") && + !normalized.contains("select 0") && + !normalized.contains("where key = ?") && + !normalized.contains("where client_conversation_id = ?") } fun cursorCell(cursor: Cursor, index: Int): SnapshotCell { @@ -205,17 +244,33 @@ class PerformanceMode : Feature("Performance Mode") { } } - fun snapshotFromCursor(cursor: Cursor): CursorSnapshot { - val columns = cursor.columnNames.toList() - val rows = mutableListOf>() - if (cursor.moveToFirst()) { - var rowCount = 0 - do { - rows += columns.indices.map { index -> cursorCell(cursor, index) } - rowCount++ - } while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext()) + fun snapshotFromCursor(cursor: Cursor): CursorSnapshot? { + val originalPosition = cursor.position + val snapshot = runCatching { + val columns = cursor.columnNames.toList() + val rows = mutableListOf>() + if (cursor.moveToFirst()) { + var rowCount = 0 + do { + rows += columns.indices.map { index -> cursorCell(cursor, index) } + rowCount++ + } while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext()) + } + CursorSnapshot(columns, rows) + }.onFailure { + context.log.error("Failed to snapshot chat feed cursor", it, "PerformanceMode") + }.getOrNull() + + runCatching { cursor.moveToPosition(originalPosition) } + val restoredPosition = runCatching { cursor.position }.getOrNull() + if (restoredPosition != originalPosition) { + context.log.warn( + "Skipping chat feed snapshot write due non-restorable cursor position (from=$originalPosition to=${restoredPosition ?: "unknown"})", + "PerformanceMode" + ) + return null } - return CursorSnapshot(columns, rows) + return snapshot } fun snapshotToMatrixCursor(snapshot: CursorSnapshot): MatrixCursor { @@ -234,16 +289,42 @@ class PerformanceMode : Feature("Performance Mode") { } } - fun readSnapshot(file: File): CursorSnapshot? { + fun readSnapshot(file: File, expectedQueryKey: String): CursorSnapshot? { return runCatching { if (!file.exists()) return null - context.gson.fromJson(file.readText(Charsets.UTF_8), CursorSnapshot::class.java) - }.getOrNull() + val cache = context.gson.fromJson(file.readText(Charsets.UTF_8), ChatFeedSnapshotCache::class.java) ?: return null + if (cache.schemaVersion != CHAT_FEED_CACHE_SCHEMA_VERSION) { + runCatching { file.delete() } + return null + } + if (cache.queryKey != expectedQueryKey) { + runCatching { file.delete() } + return null + } + if (System.currentTimeMillis() - cache.createdAt > CHAT_FEED_CACHE_MAX_AGE_MS) { + runCatching { file.delete() } + return null + } + cache.snapshot + }.getOrElse { + runCatching { file.delete() } + null + } } - fun writeSnapshot(file: File, snapshot: CursorSnapshot) { + fun writeSnapshot(file: File, queryKey: String, snapshot: CursorSnapshot) { runCatching { - file.writeText(context.gson.toJson(snapshot), Charsets.UTF_8) + file.writeText( + context.gson.toJson( + ChatFeedSnapshotCache( + schemaVersion = CHAT_FEED_CACHE_SCHEMA_VERSION, + queryKey = queryKey, + createdAt = System.currentTimeMillis(), + snapshot = snapshot, + ) + ), + Charsets.UTF_8 + ) }.onFailure { context.log.error("Failed to persist friend list snapshot", it, "PerformanceMode") } @@ -253,10 +334,7 @@ class PerformanceMode : Feature("Performance Mode") { if (!isMaxProfile) return@subscribe val url = event.url if (url.contains("ami/friends")) { - if (chatFeedSnapshotFile.exists()) { - chatFeedSnapshotFile.delete() - context.log.info("Invalidated chat feed snapshot after friends mutation sync", "PerformanceMode") - } + invalidateChatFeedSnapshot("friends-mutation-sync") } if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) { event.canceled = true @@ -608,7 +686,8 @@ class PerformanceMode : Feature("Performance Mode") { val sql = param.argNullable(1) ?: return@hook if (!isChatFeedQuery(sql)) return@hook if (chatFeedSnapshotServedThisProcess.get()) return@hook - readSnapshot(chatFeedSnapshotFile)?.let { snapshot -> + val queryKey = buildChatFeedSnapshotQueryKey(sql) + readSnapshot(chatFeedSnapshotFile, queryKey)?.let { snapshot -> param.setResult(snapshotToMatrixCursor(snapshot)) chatFeedSnapshotServedThisProcess.set(true) } @@ -618,13 +697,13 @@ 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) + val queryKey = buildChatFeedSnapshotQueryKey(sql) + val snapshot = snapshotFromCursor(cursor) ?: return@hook if (snapshot.rows.isEmpty()) return@hook - writeSnapshot(chatFeedSnapshotFile, snapshot) + writeSnapshot(chatFeedSnapshotFile, queryKey, snapshot) lastChatFeedSnapshotWrite.set(now) } }.onFailure {