From ed6786e65b380cf1416b4b489290781639ec1480 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:31:56 +0530 Subject: [PATCH 01/33] Add handling for Snapchat Plus purchase date property --- .../ui/manager/pages/features/FeaturesRootSection.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index 764580e8..054ed05a 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -779,11 +779,14 @@ class FeaturesRootSection : Routes.Route() { DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { val isMessageListProperty = property.key.name.endsWith("_messages") val isSleepWindowProperty = property.key.name.contains("sleep_window") + val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date" if (isMessageListProperty) { alertDialogs.MessageListPropertyDialog(property) { showDialog = false } } else if (isSleepWindowProperty) { alertDialogs.AutoOpenScheduleDialog(property as PropertyPair) { showDialog = false } + } else if (isSnapchatPlusPurchaseDateProperty) { + alertDialogs.DatePickerPropertyDialog(property) { showDialog = false } } else { alertDialogs.KeyboardInputDialog(property) { showDialog = false } } @@ -801,6 +804,7 @@ class FeaturesRootSection : Routes.Route() { ) } else { val isMessageListProperty = property.key.name.endsWith("_messages") + val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date" if (isMessageListProperty) { val messageCount = try { val messageList: List = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList() @@ -822,6 +826,11 @@ class FeaturesRootSection : Routes.Route() { color = Color.White ) } + } else if (isSnapchatPlusPurchaseDateProperty) { + ValueGlowChip( + text = propertyValue.get().toString(), + onClick = click + ) } else { IconButton(onClick = click) { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) From 810afd88f7f1c55ca190dfa8c5e56772457e3fc7 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:34:12 +0530 Subject: [PATCH 02/33] Implement DatePickerPropertyDialog for date selection Added a DatePickerPropertyDialog composable for selecting dates. --- .../purrfectsnap/ui/util/AlertDialogs.kt | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt index d386e1fa..b46892e9 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt @@ -75,6 +75,10 @@ import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Overlay import java.io.File +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog @@ -512,6 +516,68 @@ class AlertDialogs( } } + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) { + val context = LocalContext.current + val zoneId = remember { ZoneId.systemDefault() } + val initialSelectedDateMillis = remember(property.value.get()) { + runCatching { + LocalDate + .parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE) + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli() + }.getOrNull() + } + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis) + + DefaultDialogCard { + DatePicker( + state = datePickerState, + showModeToggle = true + ) + + Row( + modifier = Modifier + .padding(top = 10.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + ) { + Button( + onClick = { dismiss() }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White.copy(alpha = 0.08f), + contentColor = Color.White + ) + ) { + Text(text = translation["button.cancel"]) + } + Button( + onClick = { + val selectedDate = datePickerState.selectedDateMillis?.let { + Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate() + } + + if (selectedDate == null) { + Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show() + return@Button + } + + property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)) + dismiss() + }, + colors = ButtonDefaults.buttonColors( + containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f), + contentColor = Color.White + ) + ) { + Text(text = translation["button.ok"]) + } + } + } + } + @Composable fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) { val focusRequester = remember { FocusRequester() } @@ -1615,4 +1681,3 @@ class AlertDialogs( } } } - From 4f0db150b064a13a401c745397601ee7c6fff378 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:35:24 +0530 Subject: [PATCH 03/33] Enhance Snapchat Plus with custom purchase date options Added properties for custom purchase date in Snapchat Plus. --- common/src/main/assets/lang/en_US.json | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 3cbbacd6..44af2f65 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -1900,7 +1900,17 @@ }, "snapchat_plus": { "name": "Snapchat Plus", - "description": "Enables Snapchat Plus features\nSome Server-sided features may not work" + "description": "Enables Snapchat Plus features\nSome Server-sided features may not work", + "properties": { + "snapchat_plus_custom_purchase_date": { + "name": "Use Custom Purchase Date", + "description": "Use your own Snapchat Plus purchase date override" + }, + "snapchat_plus_purchase_date": { + "name": "Purchase Date (YYYY-MM-DD)", + "description": "Set a custom Snapchat Plus purchase date in YYYY-MM-DD format" + } + } }, "media_upload_quality": { "name": "Media Upload Quality", From 7d7488d05e396c139788299dc4d66813a3ffa83c Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:36:36 +0530 Subject: [PATCH 04/33] Add Snapchat Plus purchase date configurations Added custom purchase date and purchase date fields for Snapchat Plus with input validation. --- .../eternal/purrfectsnap/common/config/impl/Global.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index d7f3f223..c0f5449e 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -3,6 +3,8 @@ package me.eternal.purrfectsnap.common.config.impl import me.eternal.purrfectsnap.common.config.ConfigContainer import me.eternal.purrfectsnap.common.config.ConfigFlag import me.eternal.purrfectsnap.common.config.FeatureNotice +import java.time.LocalDate +import java.time.format.DateTimeFormatter class Global : ConfigContainer() { companion object { @@ -46,6 +48,15 @@ class Global : ConfigContainer() { val betterLocation = container("better_location", BetterLocationConfig()) val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() } + val snapchatPlusCustomPurchaseDate = boolean("snapchat_plus_custom_purchase_date") { requireRestart() } + val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "2026-04-14") { + requireRestart() + inputCheck = { + runCatching { + LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) + }.isSuccess + } + } val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig()) val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply { profile.set("max") From b682ee8432274db2df60660408c1f6c43e0d02a7 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:37:20 +0530 Subject: [PATCH 05/33] Add custom purchase date handling for SnapchatPlus --- .../core/features/impl/global/SnapchatPlus.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt index 048e9ad1..ba08a896 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt @@ -8,9 +8,11 @@ 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.mapper.impl.PlusSubscriptionMapper +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter class SnapchatPlus: Feature("SnapchatPlus") { - private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L) private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L) override fun init() { @@ -40,7 +42,23 @@ class SnapchatPlus: Feature("SnapchatPlus") { //subscription status set(statusField.getAsString()!!, 2) - set(originalSubscriptionTimeMillisField.getAsString()!!, originalSubscriptionTime) + val fallbackOriginalSubscriptionTime = System.currentTimeMillis() - 7776000000L + val customPurchaseDateMillis = if (context.config.global.snapchatPlusCustomPurchaseDate.get()) { + runCatching { + LocalDate + .parse(context.config.global.snapchatPlusPurchaseDate.get(), DateTimeFormatter.ISO_LOCAL_DATE) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() + } else { + null + } + + set( + originalSubscriptionTimeMillisField.getAsString()!!, + customPurchaseDateMillis ?: fallbackOriginalSubscriptionTime + ) set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis) } } From ba607fb419593a5c63d76da031572d3fe461197f Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:02:47 +0530 Subject: [PATCH 06/33] Add process termination functionality to BridgeService --- .../me/eternal/purrfectsnap/bridge/BridgeService.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 9d56d813..2de3dd1b 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.bridge import android.app.Service import android.content.Intent +import android.os.Process import android.os.IBinder import android.os.ParcelFileDescriptor import android.os.RemoteException @@ -25,6 +26,7 @@ import me.eternal.purrfectsnap.task.TaskType import java.io.File import java.util.UUID import kotlin.system.measureTimeMillis +import kotlin.system.exitProcess class BridgeService : Service() { private lateinit var remoteSideContext: RemoteSideContext @@ -295,6 +297,12 @@ class BridgeService : Service() { return remoteSideContext.sharedPreferences.all["debug_$key"]?.toString() ?: defaultValue } + override fun terminateModuleProcess() { + remoteSideContext.log.info("Terminating PurrfectSnap module process by request") + Process.killProcess(Process.myPid()) + exitProcess(0) + } + override fun startCallDownload( startTimestamp: Long, author: String From 6f0639b8e91a25aaaa474b822387d9b1581d9144 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:04:36 +0530 Subject: [PATCH 07/33] Add terminateModuleProcess method to BridgeInterface --- .../aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl index f86ac18e..fbfee88e 100644 --- a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl +++ b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl @@ -107,5 +107,7 @@ interface BridgeInterface { @nullable String getDebugProp(String key, @nullable String defaultValue); + oneway void terminateModuleProcess(); + CallDownloadSession startCallDownload(long startTimestamp, String author); } From e89628e5c2a8db784de2391f4f0fc5290722bf0d Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:06:15 +0530 Subject: [PATCH 08/33] Add chat button hold kill feature Added chat button hold kill feature with properties. --- common/src/main/assets/lang/en_US.json | 32 +++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 44af2f65..2194d819 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -1231,6 +1231,20 @@ "name": "Settings Menu", "description": "Choose between the new and legacy settings menu layouts" }, + "chat_button_hold_kill": { + "name": "Chat Button Hold Kill", + "description": "Hold the chat button in friend feed to kill Snapchat and/or PurrfectSnap", + "properties": { + "enabled": { + "name": "Enable Hold Kill", + "description": "Enable long-press kill action on the chat button" + }, + "target_apps": { + "name": "Target Apps", + "description": "Choose which apps are terminated when long-pressing the chat button" + } + } + }, "spoof_snap_score": { "name": "Spoof Snap Score", "description": "Spoof your Snap Score (local only)", @@ -1900,17 +1914,7 @@ }, "snapchat_plus": { "name": "Snapchat Plus", - "description": "Enables Snapchat Plus features\nSome Server-sided features may not work", - "properties": { - "snapchat_plus_custom_purchase_date": { - "name": "Use Custom Purchase Date", - "description": "Use your own Snapchat Plus purchase date override" - }, - "snapchat_plus_purchase_date": { - "name": "Purchase Date (YYYY-MM-DD)", - "description": "Set a custom Snapchat Plus purchase date in YYYY-MM-DD format" - } - } + "description": "Enables Snapchat Plus features\nSome Server-sided features may not work" }, "media_upload_quality": { "name": "Media Upload Quality", @@ -2894,6 +2898,12 @@ "default": "Default", "legacy": "Legacy" }, + "chat_button_hold_kill": { + "target_apps": { + "kill_snapchat": "Kill Snapchat", + "kill_purrfectsnap": "Kill PurrfectSnap" + } + }, "path_format": { "create_author_folder": "Create folder for each author", "create_source_folder": "Create folder for each media source type", From 2c9f5d45e2139067f60d023f8d55f3c538ed6c40 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:07:07 +0530 Subject: [PATCH 09/33] Add ChatButtonHoldKill configuration options --- .../common/config/impl/UserInterfaceTweaks.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index c0bcdd0a..2b9fc6d8 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -19,6 +19,15 @@ class UserInterfaceTweaks : ConfigContainer() { val amount = integer("amount", defaultValue = 1) } + inner class ChatButtonHoldKill : ConfigContainer(hasGlobalState = true) { + val enabled = boolean("enabled") + val targetApps = multiple("target_apps", "kill_snapchat", "kill_purrfectsnap") { + customOptionTranslationPath = "features.options.chat_button_hold_kill.target_apps" + }.apply { + set(mutableListOf("kill_snapchat")) + } + } + val friendFeedMenuButtons = multiple( "friend_feed_menu_buttons","conversation_info", "mark_chat_as_read", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() @@ -63,6 +72,7 @@ class UserInterfaceTweaks : ConfigContainer() { } val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() } val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") } + val chatButtonHoldKill = container("chat_button_hold_kill", ChatButtonHoldKill()) { requireRestart() } inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) { val customSnapScore = string("custom_snap_score") { From 25dd438da21431c440474293e0ea3dc184a8c1ed Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:07:59 +0530 Subject: [PATCH 10/33] Add terminateModuleProcess function to BridgeClient --- .../kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 276a7510..0de79cc4 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 @@ -320,6 +320,8 @@ class BridgeClient( fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) } + fun terminateModuleProcess() = safeServiceCall { service.terminateModuleProcess() } + fun startCallDownload( startTimestamp: Long, author: String, @@ -327,4 +329,3 @@ class BridgeClient( return safeServiceCall { service.startCallDownload(startTimestamp, author) } } } - From a859d4825b1c33575eaca54137f8c5791b7a67e6 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:09:00 +0530 Subject: [PATCH 11/33] Implement long click listener for chat button Add long click listener to handle app termination based on user settings. --- .../core/ui/menu/impl/SettingsMenu.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt index f9052bf0..bb74b4e0 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt @@ -22,6 +22,29 @@ class SettingsMenu : AbstractMenu() { view.setOnClickListener { context.bridgeClient.openOverlay(OverlayType.SETTINGS) } + view.setOnLongClickListener { + val holdKillConfig = context.config.userInterface.chatButtonHoldKill + if (!holdKillConfig.enabled.get()) { + return@setOnLongClickListener false + } + + val targetApps = holdKillConfig.targetApps.get() + val shouldKillModule = targetApps.contains("kill_purrfectsnap") + val shouldKillSnapchat = targetApps.contains("kill_snapchat") + + if (shouldKillModule) { + runCatching { + context.bridgeClient.terminateModuleProcess() + }.onFailure { + context.log.error("Failed to terminate PurrfectSnap module process", it, "SettingsMenu") + } + } + if (shouldKillSnapchat) { + context.forceCloseApp() + } + + shouldKillModule || shouldKillSnapchat + } } } } From 0689cc2005d89f56764689dc39b39c1317fedba3 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:20:22 +0530 Subject: [PATCH 12/33] Replace ValueGlowChip with Button for purchase date --- .../manager/pages/features/FeaturesRootSection.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index 054ed05a..f03f0c4e 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -827,10 +827,15 @@ class FeaturesRootSection : Routes.Route() { ) } } else if (isSnapchatPlusPurchaseDateProperty) { - ValueGlowChip( - text = propertyValue.get().toString(), - onClick = click - ) + Button( + onClick = click, + colors = ButtonDefaults.buttonColors( + containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f), + contentColor = Color.White + ) + ) { + Text(translation["button.save"] ?: "Set") + } } else { IconButton(onClick = click) { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) From 61bcb6d7437ea15a1e8b096f3e53aaeb32540c4f Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:21:18 +0530 Subject: [PATCH 13/33] Fix missing newline at end of AlertDialogs.kt From 7b7bceec803309d7ba15f1af01afe0fa0e8b354b Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:25:29 +0530 Subject: [PATCH 14/33] Update settings in en_US.json Removed chat button hold kill settings and added Snapchat Plus purchase date. --- common/src/main/assets/lang/en_US.json | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 2194d819..44c51794 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -1231,20 +1231,6 @@ "name": "Settings Menu", "description": "Choose between the new and legacy settings menu layouts" }, - "chat_button_hold_kill": { - "name": "Chat Button Hold Kill", - "description": "Hold the chat button in friend feed to kill Snapchat and/or PurrfectSnap", - "properties": { - "enabled": { - "name": "Enable Hold Kill", - "description": "Enable long-press kill action on the chat button" - }, - "target_apps": { - "name": "Target Apps", - "description": "Choose which apps are terminated when long-pressing the chat button" - } - } - }, "spoof_snap_score": { "name": "Spoof Snap Score", "description": "Spoof your Snap Score (local only)", @@ -1916,6 +1902,10 @@ "name": "Snapchat Plus", "description": "Enables Snapchat Plus features\nSome Server-sided features may not work" }, + "snapchat_plus_purchase_date": { + "name": "Snapchat Plus Purchase Date", + "description": "Tap Save to choose a date from calendar (leave empty to use default)" + }, "media_upload_quality": { "name": "Media Upload Quality", "description": "Overrides the media upload quality", @@ -2898,12 +2888,6 @@ "default": "Default", "legacy": "Legacy" }, - "chat_button_hold_kill": { - "target_apps": { - "kill_snapchat": "Kill Snapchat", - "kill_purrfectsnap": "Kill PurrfectSnap" - } - }, "path_format": { "create_author_folder": "Create folder for each author", "create_source_folder": "Create folder for each media source type", From df76d13904ce78cd71296f4f1593322469681c79 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:27:03 +0530 Subject: [PATCH 15/33] Refactor snapchatPlusPurchaseDate initialization --- .../me/eternal/purrfectsnap/common/config/impl/Global.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index c0f5449e..d2d24343 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -48,13 +48,10 @@ class Global : ConfigContainer() { val betterLocation = container("better_location", BetterLocationConfig()) val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() } - val snapchatPlusCustomPurchaseDate = boolean("snapchat_plus_custom_purchase_date") { requireRestart() } - val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "2026-04-14") { + val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "") { requireRestart() inputCheck = { - runCatching { - LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) - }.isSuccess + it.isBlank() || runCatching { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }.isSuccess } } val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig()) From 6f4e746a6606e749d94782f75453af4cb5a31a6f Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:27:54 +0530 Subject: [PATCH 16/33] Refactor custom purchase date handling in SnapchatPlus --- .../purrfectsnap/core/features/impl/global/SnapchatPlus.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt index ba08a896..aa5fbb98 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt @@ -43,10 +43,11 @@ class SnapchatPlus: Feature("SnapchatPlus") { set(statusField.getAsString()!!, 2) val fallbackOriginalSubscriptionTime = System.currentTimeMillis() - 7776000000L - val customPurchaseDateMillis = if (context.config.global.snapchatPlusCustomPurchaseDate.get()) { + val customPurchaseDate = context.config.global.snapchatPlusPurchaseDate.get().trim() + val customPurchaseDateMillis = if (customPurchaseDate.isNotEmpty()) { runCatching { LocalDate - .parse(context.config.global.snapchatPlusPurchaseDate.get(), DateTimeFormatter.ISO_LOCAL_DATE) + .parse(customPurchaseDate, DateTimeFormatter.ISO_LOCAL_DATE) .atStartOfDay(ZoneId.systemDefault()) .toInstant() .toEpochMilli() From 4e3ef029a17a63c5475e651bc505417bae9c3f1c Mon Sep 17 00:00:00 2001 From: Sujal Sahu Date: Tue, 14 Apr 2026 14:59:54 +0000 Subject: [PATCH 17/33] fix(PR): Multiple fixes by Kaladin - Auto Open Engine refactor and implemented the engine stop button in the notification card. - Media downloader stabilization fixes including Batch download fix. - Implemented a new log filtering menu. - Cargo Dependency updated to latest stable versions. - Minor bug fixes. --- .../purrfectsnap/bridge/BridgeService.kt | 8 - .../download/DownloadProcessor.kt | 37 +- .../purrfectsnap/download/FFMpegProcessor.kt | 78 +- .../task/AnnouncementCheckWorker.kt | 3 +- .../pages/features/FeaturesRootSection.kt | 14 - .../ui/manager/pages/home/HomeLogs.kt | 40 + .../pages/themes/aphelion/AphelionLogsView.kt | 77 +- .../pages/themes/legacy/LegacyTheme.kt | 68 + .../purrfectsnap/ui/util/AlertDialogs.kt | 67 +- .../purrfectsnap/bridge/BridgeInterface.aidl | 2 - common/src/main/assets/lang/en_US.json | 33 +- .../common/config/impl/DownloaderConfig.kt | 6 +- .../common/config/impl/Experimental.kt | 1 + .../purrfectsnap/common/config/impl/Global.kt | 8 - .../common/config/impl/MessagingTweaks.kt | 8 +- .../common/config/impl/UserInterfaceTweaks.kt | 10 - .../common/scripting/ScriptRuntime.kt | 2 +- .../util/ktx/AndroidCompatExtensions.kt | 14 +- .../eternal/purrfectsnap/core/ModContext.kt | 8 +- .../eternal/purrfectsnap/core/PurrfectSnap.kt | 1 + .../purrfectsnap/core/bridge/BridgeClient.kt | 3 +- .../impl/downloader/MediaDownloader.kt | 1227 +++++------------ .../impl/experiments/AutoOpenSnaps.kt | 782 +++++------ .../core/features/impl/global/SnapchatPlus.kt | 23 +- .../features/impl/messaging/Notifications.kt | 19 +- .../features/impl/ui/ConversationToolbox.kt | 1 + .../core/scripting/CoreScriptRuntime.kt | 32 +- .../core/ui/menu/impl/FriendFeedInfoMenu.kt | 1 + .../core/ui/menu/impl/SettingsMenu.kt | 23 - .../core/wrapper/impl/media/opera/ParamMap.kt | 32 + native/rust/Cargo.lock | 36 +- native/rust/src/config.rs | 6 + native/rust/src/modules/custom_font_hook.rs | 34 +- native/rust/src/modules/util/valdi_utils.rs | 22 +- native/rust/src/modules/valdi_hook.rs | 137 +- .../purrfectsnap/nativelib/NativeConfig.kt | 9 + 36 files changed, 1209 insertions(+), 1663 deletions(-) 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 2de3dd1b..9d56d813 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -2,7 +2,6 @@ package me.eternal.purrfectsnap.bridge import android.app.Service import android.content.Intent -import android.os.Process import android.os.IBinder import android.os.ParcelFileDescriptor import android.os.RemoteException @@ -26,7 +25,6 @@ import me.eternal.purrfectsnap.task.TaskType import java.io.File import java.util.UUID import kotlin.system.measureTimeMillis -import kotlin.system.exitProcess class BridgeService : Service() { private lateinit var remoteSideContext: RemoteSideContext @@ -297,12 +295,6 @@ class BridgeService : Service() { return remoteSideContext.sharedPreferences.all["debug_$key"]?.toString() ?: defaultValue } - override fun terminateModuleProcess() { - remoteSideContext.log.info("Terminating PurrfectSnap module process by request") - Process.killProcess(Process.myPid()) - exitProcess(0) - } - override fun startCallDownload( startTimestamp: Long, author: String diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt index b5c43e64..6a1a7d84 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt @@ -648,8 +648,41 @@ class DownloadProcessor ( val media = downloadedMedias.entries.first { !it.key.isOverlay }.value val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value - val renamedMedia = renameFromFileType(media, FileType.fromFile(media)) - val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia)) + val mediaFileType = FileType.fromFile(media) + val overlayFileType = FileType.fromFile(overlayMedia) + + val renamedMedia = renameFromFileType(media, mediaFileType) + val renamedOverlayMedia = renameFromFileType(overlayMedia, overlayFileType) + + if (mediaFileType.isImage && overlayFileType.isImage) { + runCatching { + callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension)) + val originalBitmap = BitmapFactory.decodeFile(renamedMedia.absolutePath) ?: throw Exception("Failed to decode original image") + val overlayBitmap = BitmapFactory.decodeFile(renamedOverlayMedia.absolutePath) ?: throw Exception("Failed to decode overlay image") + + val mergedBitmap = me.eternal.purrfectsnap.core.util.media.PreviewUtils.mergeBitmapOverlay(originalBitmap, overlayBitmap) + val mergedImage: File = File.createTempFile("merged", "." + (mediaFileType.fileExtension ?: "jpg")) + + val compressFormat = when (mediaFileType) { + FileType.PNG -> Bitmap.CompressFormat.PNG + FileType.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP + else -> Bitmap.CompressFormat.JPEG + } + + mergedImage.outputStream().use { + mergedBitmap.compress(compressFormat, 100, it) + } + + saveMediaToGallery(pendingTask, mergedImage, downloadMetadata) + mergedImage.delete() + renamedOverlayMedia.delete() + renamedMedia.delete() + return@launch + }.onFailure { + remoteSideContext.log.error("Failed to merge image overlay using Bitmap, falling back to FFmpeg", it) + } + } + val mergedOverlay: File = File.createTempFile("merged", ".mp4") runCatching { callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension)) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt index 69762b99..86c0b053 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -90,6 +90,8 @@ class FFMpegProcessor( ) + private val sharedExecutor = Executors.newSingleThreadExecutor() + private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine { val stringBuilder = StringBuilder() arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> @@ -127,7 +129,7 @@ class FFMpegProcessor( Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE else -> return@logFunction }, log.message) - }, { onStatistics(it) }, Executors.newSingleThreadExecutor()) + }, { onStatistics(it) }, sharedExecutor) } suspend fun execute(args: Request) { @@ -162,7 +164,7 @@ class FFMpegProcessor( } Action.MERGE_OVERLAY -> { inputArguments += "-i" to args.overlay!!.absolutePath - outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" + outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)\"" } Action.CONVERSION -> { if (ffmpegOptions.customAudioCodec.isEmpty()) { @@ -187,45 +189,47 @@ class FFMpegProcessor( }.getOrNull()?.let { file to it } } - val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) -> - r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 - }?.let { (_, r) -> - r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to - r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() - } ?: throw Exception("Failed to get video size") + try { + val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) -> + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 + }?.let { (_, r) -> + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() + } ?: throw Exception("Failed to get video size") - val filterFirstPart = StringBuilder() - val filterSecondPart = StringBuilder() - var containsNoSound = false + val filterFirstPart = StringBuilder() + val filterSecondPart = StringBuilder() + var containsNoSound = false - filesInfo.forEachIndexed { index, (file, retriever) -> - filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];") - if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") { - filterSecondPart.append("[v$index][$index:a]") - } else { - containsNoSound = true - filterSecondPart.append("[v$index][${filesInfo.size}]") + filesInfo.forEachIndexed { index, (file, retriever) -> + filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];") + if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") { + filterSecondPart.append("[v$index][$index:a]") + } else { + containsNoSound = true + filterSecondPart.append("[v$index][${filesInfo.size}]") + } + inputArguments += "-i" to file } - inputArguments += "-i" to file + + if (containsNoSound) { + inputArguments += "-f" to "lavfi" + inputArguments += "-t" to "0.1" + inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100" + } + + if (outputArguments["-c:a"] == "copy") { + outputArguments -= "-c:a" + } + + outputArguments += "-fps_mode" to "vfr" + + outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\"" + outputArguments += "-map" to "\"[aout]\"" + outputArguments += "-map" to "\"[vout]\"" + } finally { + filesInfo.forEach { it.second.close() } } - - if (containsNoSound) { - inputArguments += "-f" to "lavfi" - inputArguments += "-t" to "0.1" - inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100" - } - - if (outputArguments["-c:a"] == "copy") { - outputArguments -= "-c:a" - } - - outputArguments += "-fps_mode" to "vfr" - - outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\"" - outputArguments += "-map" to "\"[aout]\"" - outputArguments += "-map" to "\"[vout]\"" - - filesInfo.forEach { it.second.close() } } Action.DOWNLOAD_AUDIO_STREAM -> { outputArguments.clear() diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt index 3a611432..e716ebb3 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt @@ -86,7 +86,8 @@ class AnnouncementCheckWorker( val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE) val builder = NotificationCompat.Builder(appContext, channelId) - .setSmallIcon(R.mipmap.ic_launcher) + .setSmallIcon(R.mipmap.ic_launcher_monochrome) + .setLargeIcon(android.graphics.BitmapFactory.decodeResource(appContext.resources, R.mipmap.ic_launcher)) .setContentTitle(title) .setContentText(text) .setPriority(NotificationCompat.PRIORITY_DEFAULT) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index f03f0c4e..764580e8 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -779,14 +779,11 @@ class FeaturesRootSection : Routes.Route() { DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { val isMessageListProperty = property.key.name.endsWith("_messages") val isSleepWindowProperty = property.key.name.contains("sleep_window") - val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date" if (isMessageListProperty) { alertDialogs.MessageListPropertyDialog(property) { showDialog = false } } else if (isSleepWindowProperty) { alertDialogs.AutoOpenScheduleDialog(property as PropertyPair) { showDialog = false } - } else if (isSnapchatPlusPurchaseDateProperty) { - alertDialogs.DatePickerPropertyDialog(property) { showDialog = false } } else { alertDialogs.KeyboardInputDialog(property) { showDialog = false } } @@ -804,7 +801,6 @@ class FeaturesRootSection : Routes.Route() { ) } else { val isMessageListProperty = property.key.name.endsWith("_messages") - val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date" if (isMessageListProperty) { val messageCount = try { val messageList: List = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList() @@ -826,16 +822,6 @@ class FeaturesRootSection : Routes.Route() { color = Color.White ) } - } else if (isSnapchatPlusPurchaseDateProperty) { - Button( - onClick = click, - colors = ButtonDefaults.buttonColors( - containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f), - contentColor = Color.White - ) - ) { - Text(translation["button.save"] ?: "Set") - } } else { IconButton(onClick = click) { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt index f74d02c5..5b83f12f 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Report @@ -170,6 +171,7 @@ class HomeLogs : Routes.Route() { internal fun LogsFloatingBar( isRefreshing: Boolean, onRefresh: () -> Unit, + onFilter: () -> Unit, onExport: () -> Unit, onClear: () -> Unit ) { @@ -222,6 +224,20 @@ class HomeLogs : Routes.Route() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + if (isRefreshing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = Color.White + ) + } + IconButton(onClick = onFilter) { + Icon( + imageVector = Icons.Filled.FilterList, + contentDescription = "Filter Logs", + tint = PurrfectPalette.glowSecondary + ) + } IconButton(onClick = onRefresh, enabled = !isRefreshing) { Icon( imageVector = Icons.Filled.Refresh, @@ -457,7 +473,31 @@ class HomeLogs : Routes.Route() { LogLevel.WARN -> Icons.Outlined.Warning } + enum class LogCategory(val translationKey: String, val tags: List) { + CORE("log_category_core", listOf("core", "hook", "module", "mappings")), + AUTO_OPEN("log_category_auto_open", listOf("autoopenengine", "autoopen")), + MEDIA("log_category_media", listOf("downloader", "ffmpeg", "media", "video")), + BRIDGE("log_category_bridge", listOf("messagingbridge", "bridge", "ipc")), + SYSTEM("log_category_system", listOf("systemguard", "thermal", "battery", "wakelock")), + TRACKER("log_category_tracker", listOf("tracker", "friendtracker")) + } + + val enabledCategories = mutableStateMapOf().apply { + LogCategory.entries.forEach { put(it, true) } + } + + internal fun getCategoryForLog(line: LogLine): LogCategory? { + val tag = line.tag.lowercase() + val message = line.message.lowercase() + return LogCategory.entries.find { category -> + category.tags.any { tag.contains(it) || message.contains("[$it]") } + } + } + internal fun shouldHideLog(line: LogLine): Boolean { + val category = getCategoryForLog(line) + if (category != null && enabledCategories[category] == false) return true + val message = line.message.lowercase() val tag = line.tag.lowercase() return message.startsWith("blocked ep") || diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt index e50317cb..32ada622 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt @@ -2,27 +2,31 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DeleteSweep -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog import androidx.navigation.NavBackStackEntry import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette +import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme import me.eternal.purrfectsnap.ui.util.headerHeightTracker import me.eternal.purrfectsnap.ui.util.Motion import kotlinx.coroutines.launch @@ -37,6 +41,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { var logReader by remember { mutableStateOf(null) } val visibleLogs = remember { mutableStateListOf() } var isRefreshing by remember { mutableStateOf(false) } + var showFilterDialog by remember { mutableStateOf(false) } fun refreshLogs() { isRefreshing = true @@ -69,6 +74,67 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { } } + @Composable + fun LogFilterDialog() { + Dialog(onDismissRequest = { showFilterDialog = false }) { + PurrfectOverlayTheme { + PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HomeLogs.LogCategory.entries.forEach { category -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + enabledCategories.keys.forEach { enabledCategories[it] = false } + enabledCategories[category] = true + refreshLogs() + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Checkbox( + checked = enabledCategories[category] == true, + onCheckedChange = { checked -> + enabledCategories[category] = checked + refreshLogs() + }, + colors = CheckboxDefaults.colors( + checkedColor = PurrfectPalette.glowPrimary, + uncheckedColor = Color.White.copy(alpha = 0.4f), + checkmarkColor = Color.White + ) + ) + Text( + text = translation[category.translationKey] ?: category.name, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button( + onClick = { showFilterDialog = false }, + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary) + ) { + Text(translation["filter_logs_done_button"] ?: "Done") + } + } + } + } + } + } + } + + if (showFilterDialog) { + LogFilterDialog() + } + LaunchedEffect(externalRefreshTick.value) { if (externalRefreshTick.value > 0) { refreshLogs() @@ -132,6 +198,9 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { color = Color.White ) } + IconButton(onClick = { showFilterDialog = true }) { + Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary) + } IconButton(onClick = { refreshLogs() }) { Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White) } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index db8b2942..dff2aca6 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -1121,6 +1121,8 @@ object LegacyTheme : ThemeContract { val visibleLogs = remember { mutableStateListOf() } val mainExecutor = remember { context.androidContext.mainExecutor } var isRefreshing by remember { mutableStateOf(false) } + var showFilterDialog by remember { mutableStateOf(false) } + fun refreshLogs() { coroutineScope.launch { val readerResult = withContext(Dispatchers.IO) { @@ -1154,6 +1156,71 @@ object LegacyTheme : ThemeContract { isRefreshing = false } } + + @Composable + fun LogFilterDialog() { + androidx.compose.ui.window.Dialog(onDismissRequest = { showFilterDialog = false }) { + me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme { + me.eternal.purrfectsnap.core.ui.PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HomeLogs.LogCategory.entries.forEach { category -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + // Solo Focus Logic: Tap the name to filter only this category + enabledCategories.keys.forEach { enabledCategories[it] = false } + enabledCategories[category] = true + isRefreshing = true + refreshLogs() + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Checkbox( + checked = enabledCategories[category] == true, + onCheckedChange = { checked -> + enabledCategories[category] = checked + isRefreshing = true + refreshLogs() + }, + colors = CheckboxDefaults.colors( + checkedColor = PurrfectPalette.glowPrimary, + uncheckedColor = Color.White.copy(alpha = 0.4f), + checkmarkColor = Color.White + ) + ) + Text( + text = translation[category.translationKey] ?: category.name, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button( + onClick = { showFilterDialog = false }, + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary) + ) { + Text(translation["filter_logs_done_button"] ?: "Done") + } + } + } + } + } + } + } + + if (showFilterDialog) { + LogFilterDialog() + } + LaunchedEffect(externalRefreshTick.intValue) { if (externalRefreshTick.intValue > 0) { isRefreshing = true @@ -1181,6 +1248,7 @@ object LegacyTheme : ThemeContract { isRefreshing = true refreshLogs() }, + onFilter = { showFilterDialog = true }, onExport = { exportLogs() }, onClear = { clearLogsAndReload() } ) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt index b46892e9..d386e1fa 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt @@ -75,10 +75,6 @@ import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Overlay import java.io.File -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog @@ -516,68 +512,6 @@ class AlertDialogs( } } - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) { - val context = LocalContext.current - val zoneId = remember { ZoneId.systemDefault() } - val initialSelectedDateMillis = remember(property.value.get()) { - runCatching { - LocalDate - .parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE) - .atStartOfDay(zoneId) - .toInstant() - .toEpochMilli() - }.getOrNull() - } - val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis) - - DefaultDialogCard { - DatePicker( - state = datePickerState, - showModeToggle = true - ) - - Row( - modifier = Modifier - .padding(top = 10.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), - ) { - Button( - onClick = { dismiss() }, - colors = ButtonDefaults.buttonColors( - containerColor = Color.White.copy(alpha = 0.08f), - contentColor = Color.White - ) - ) { - Text(text = translation["button.cancel"]) - } - Button( - onClick = { - val selectedDate = datePickerState.selectedDateMillis?.let { - Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate() - } - - if (selectedDate == null) { - Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show() - return@Button - } - - property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)) - dismiss() - }, - colors = ButtonDefaults.buttonColors( - containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f), - contentColor = Color.White - ) - ) { - Text(text = translation["button.ok"]) - } - } - } - } - @Composable fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) { val focusRequester = remember { FocusRequester() } @@ -1681,3 +1615,4 @@ class AlertDialogs( } } } + diff --git a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl index fbfee88e..f86ac18e 100644 --- a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl +++ b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl @@ -107,7 +107,5 @@ interface BridgeInterface { @nullable String getDebugProp(String key, @nullable String defaultValue); - oneway void terminateModuleProcess(); - CallDownloadSession startCallDownload(long startTimestamp, String author); } diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 44c51794..07bd00cc 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -256,6 +256,15 @@ "home_logs": { "no_logs_hint": "No logs available", "refresh_hint": "Pull to refresh or trigger an action to see new entries.", + "filter_logs_title": "Filter Log Categories", + "filter_logs_menu_item": "Filter Logs", + "filter_logs_done_button": "Done", + "log_category_core": "Core", + "log_category_auto_open": "Auto-Open", + "log_category_media": "Media", + "log_category_bridge": "Bridge", + "log_category_system": "System", + "log_category_tracker": "Tracker", "clear_logs_button": "Clear Logs", "export_logs_button": "Export Logs", "saving_logs_toast": "Saving logs, this may take a while ...", @@ -1676,6 +1685,14 @@ "name": "Allow Running in Background", "description": "Allows Auto Open Snaps to run in the background. Note: This will significantly drain your battery" }, + "delay_between_snaps": { + "name": "Delay Between Snaps", + "description": "The delay in milliseconds between opening each individual Snap" + }, + "delay_between_conversations": { + "name": "Delay Between Conversations", + "description": "The delay in milliseconds when switching to open Snaps from a different conversation" + }, "min_delay": { "name": "Min Delay (ms)", "description": "Minimum delay in milliseconds before opening a snap" @@ -1902,10 +1919,6 @@ "name": "Snapchat Plus", "description": "Enables Snapchat Plus features\nSome Server-sided features may not work" }, - "snapchat_plus_purchase_date": { - "name": "Snapchat Plus Purchase Date", - "description": "Tap Save to choose a date from calendar (leave empty to use default)" - }, "media_upload_quality": { "name": "Media Upload Quality", "description": "Overrides the media upload quality", @@ -2169,6 +2182,10 @@ "name": "Disable Bitmoji", "description": "Disables Friends Profile Bitmoji" }, + "debug_font_redirect": { + "name": "Debug Native Font Redirect", + "description": "Logs native font interception. For developer use only." + }, "custom_emoji_font": { "name": "Custom Emoji Font", "description": "Allows you to use a custom emoji font. Only works with .ttf fonts" @@ -3729,6 +3746,7 @@ "snap_item": "Snap {index} of {total}" }, "batch_download_complete_toast": "All snaps downloaded", + "batch_progress_toast": "Downloading {current}/{total}", "batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible." }, "streaks_reminder": { @@ -4389,8 +4407,7 @@ "deepseek": "DeepSeek", "openai": "OpenAI", "openrouter": "OpenRouter" - } - , + }, "tasks_no_tasks": "No tasks", "tasks_no_active_tasks": "No active tasks", "tasks_no_scheduled_tasks": "No scheduled snaps", @@ -4399,8 +4416,8 @@ "tasks_clear_button_description": "Clear tasks", "tasks_delete_button": "Delete", "tasks_merge_button": "Merge", - "tasks_summary_active": "{active} active · {recent} recent", - "tasks_summary_idle": "Idle · {recent} recent", + "tasks_summary_active": "{active} active \u2022 {recent} recent", + "tasks_summary_idle": "Idle \u2022 {recent} recent", "tasks_running_count": "{count} running", "tasks_tagline": "Monitor and manage background actions", "tasks_failed_to_open_file": "Failed to open file", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt index 6ca1eeb4..cc832348 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt @@ -9,9 +9,9 @@ class DownloaderConfig : ConfigContainer() { val threads = integer("threads", 4) // Bump Default Value to 4 Tested on Pixel 5 (Qualcomm Snapdragon 765G) Had no lag val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") { addFlags(ConfigFlag.NO_TRANSLATE) - } - val constantRateFactor = integer("constant_rate_factor", 30) - val videoBitrate = integer("video_bitrate", 5000) + }.apply { set("veryfast") } + val constantRateFactor = integer("constant_rate_factor", 22) + val videoBitrate = integer("video_bitrate", 8000) val audioBitrate = integer("audio_bitrate", 128) val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt index ec8b581b..e5bec0fd 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt @@ -35,6 +35,7 @@ class Experimental : ConfigContainer() { class NativeHooks : ConfigContainer() { val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() } val disableBitmoji = boolean("disable_bitmoji") + val debugFontRedirect = boolean("debug_font_redirect") { addFlags(ConfigFlag.HIDDEN) } val customEmojiFont = string("custom_emoji_font") { requireRestart() addFlags(ConfigFlag.USER_IMPORT) diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index d2d24343..d7f3f223 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -3,8 +3,6 @@ package me.eternal.purrfectsnap.common.config.impl import me.eternal.purrfectsnap.common.config.ConfigContainer import me.eternal.purrfectsnap.common.config.ConfigFlag import me.eternal.purrfectsnap.common.config.FeatureNotice -import java.time.LocalDate -import java.time.format.DateTimeFormatter class Global : ConfigContainer() { companion object { @@ -48,12 +46,6 @@ class Global : ConfigContainer() { val betterLocation = container("better_location", BetterLocationConfig()) val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() } - val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "") { - requireRestart() - inputCheck = { - it.isBlank() || runCatching { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }.isSuccess - } - } val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig()) val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply { profile.set("max") diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt index 95533ed8..30ff3532 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt @@ -166,12 +166,18 @@ class MessagingTweaks : ConfigContainer() { val maxDelayMs = integer("max_delay_ms", defaultValue = 100) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && it.toInt() > minDelay.get() } } - val queueSize = integer("queue_size", defaultValue = 1000) { + val queueSize = integer("queue_size", defaultValue = 700) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null } } val retryAttempts = integer("retry_attempts", defaultValue = 5) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null } } + val delayBetweenSnaps = integer("delay_between_snaps", defaultValue = 100) { + inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null } + } + val delayBetweenConversations = integer("delay_between_conversations", defaultValue = 500) { + inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null } + } val retryDelay = integer("retry_delay", defaultValue = 3000) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null } } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index 2b9fc6d8..c0bcdd0a 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -19,15 +19,6 @@ class UserInterfaceTweaks : ConfigContainer() { val amount = integer("amount", defaultValue = 1) } - inner class ChatButtonHoldKill : ConfigContainer(hasGlobalState = true) { - val enabled = boolean("enabled") - val targetApps = multiple("target_apps", "kill_snapchat", "kill_purrfectsnap") { - customOptionTranslationPath = "features.options.chat_button_hold_kill.target_apps" - }.apply { - set(mutableListOf("kill_snapchat")) - } - } - val friendFeedMenuButtons = multiple( "friend_feed_menu_buttons","conversation_info", "mark_chat_as_read", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray() @@ -72,7 +63,6 @@ class UserInterfaceTweaks : ConfigContainer() { } val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() } val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") } - val chatButtonHoldKill = container("chat_button_hold_kill", ChatButtonHoldKill()) { requireRestart() } inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) { val customSnapScore = string("custom_snap_score") { diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/scripting/ScriptRuntime.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/scripting/ScriptRuntime.kt index c0fd0d53..49303cb8 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/scripting/ScriptRuntime.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/scripting/ScriptRuntime.kt @@ -22,7 +22,7 @@ open class ScriptRuntime( private val modules = mutableMapOf() - fun eachModule(f: JSModule.() -> Unit) { + open fun eachModule(f: JSModule.() -> Unit) { modules.values.forEach { module -> runCatching { module.f() diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/util/ktx/AndroidCompatExtensions.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/util/ktx/AndroidCompatExtensions.kt index 0ba83ca8..4fc04daf 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/util/ktx/AndroidCompatExtensions.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/util/ktx/AndroidCompatExtensions.kt @@ -59,12 +59,14 @@ fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFi val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1]) coroutineScope.launch(Dispatchers.IO) { - try { - copyTo(fos) - } finally { - close() - fos.flush() - fos.close() + runCatching { + try { + copyTo(fos) + } finally { + close() + fos.flush() + fos.close() + } } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ModContext.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ModContext.kt index 8ed072cd..8386662b 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ModContext.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ModContext.kt @@ -164,10 +164,10 @@ class ModContext( disableMetrics = config.global.disableMetrics.get(), valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q, - customEmojiFontPath = getCustomEmojiFontPath(this) - ) - ) - } + customEmojiFontPath = getCustomEmojiFontPath(this), + debugFontRedirect = config.experimental.nativeHooks.debugFontRedirect.get() + ) + ) } fun getConfigLocale(): String { return _config.locale 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 b7a33662..cfc03f79 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt @@ -1,5 +1,6 @@ package me.eternal.purrfectsnap.core +import me.eternal.purrfectsnap.common.scripting.JSModule import android.app.Activity import android.content.Context import android.content.Intent 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 0de79cc4..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 @@ -320,8 +320,6 @@ class BridgeClient( fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) } - fun terminateModuleProcess() = safeServiceCall { service.terminateModuleProcess() } - fun startCallDownload( startTimestamp: Long, author: String, @@ -329,3 +327,4 @@ class BridgeClient( return safeServiceCall { service.startCallDownload(startTimestamp, author) } } } + diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 8f91aeb6..9d0b76b5 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -4,9 +4,7 @@ import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri -import android.media.MediaMetadataRetriever import android.view.Gravity -import android.view.ViewGroup.MarginLayoutParams import android.widget.ImageView import android.widget.LinearLayout import android.widget.ProgressBar @@ -14,11 +12,9 @@ import android.widget.TextView import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -29,27 +25,19 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog -import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard -import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette -import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.Dispatchers import me.eternal.purrfectsnap.bridge.DownloadCallback import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.FileType @@ -57,19 +45,22 @@ import me.eternal.purrfectsnap.common.data.MessagingRuleType import me.eternal.purrfectsnap.common.data.download.* import me.eternal.purrfectsnap.common.database.impl.ConversationMessage import me.eternal.purrfectsnap.common.database.impl.FriendInfo +import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard import me.eternal.purrfectsnap.common.util.ktx.longHashCode import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie import me.eternal.purrfectsnap.common.util.snap.MediaDownloaderHelper -import me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver import me.eternal.purrfectsnap.core.DownloadManagerClient import me.eternal.purrfectsnap.core.PurrfectSnap import me.eternal.purrfectsnap.core.features.MessagingRuleFeature import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging -import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger +import me.eternal.purrfectsnap.core.features.impl.ui.OperaStoryOverlay +import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper import me.eternal.purrfectsnap.core.ui.debugEditText import me.eternal.purrfectsnap.core.util.hook.HookStage @@ -80,35 +71,14 @@ import me.eternal.purrfectsnap.core.util.isSnapchatVersionAtLeast import me.eternal.purrfectsnap.core.util.media.PreviewUtils import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID import me.eternal.purrfectsnap.core.wrapper.impl.media.MediaInfo -import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.LongformVideoPlaylistItem -import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.SnapPlaylistItem import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair -import me.eternal.purrfectsnap.core.features.impl.ui.OperaStoryOverlay -import me.eternal.purrfectsnap.core.wrapper.impl.media.EncryptionWrapper import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper -import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode -import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPairUrlSafe -import me.eternal.purrfectsnap.core.wrapper.impl.media.HybridEncryptionResolver -import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper -import okhttp3.OkHttpClient -import okhttp3.Request -import java.nio.file.Paths import java.util.UUID -import java.util.Collections -import java.util.IdentityHashMap +import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.math.absoluteValue -import android.util.Base64 -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -class SnapChapterInfo( - val offset: Long, - val duration: Long? -) data class OperaViewerMessageContext( val conversationId: String, @@ -119,35 +89,53 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private var lastSeenMediaInfoMap: MutableMap? = null var lastSeenMapParams: ParamMap? = null private set + @Volatile + private var pendingBatchDownloadIndices: MutableList? = null + @Volatile + private var batchForceAllowDuplicate: Boolean = false private val translations by lazy { - context.translation.getCategory("download_processor") + this@MediaDownloader.context.translation.getCategory("download_processor") } private val useModernOperaViewerContext by lazy { isSnapchatVersionAtLeast( - context.mappings.getSnapchatPackageInfo()?.versionName, + this@MediaDownloader.context.mappings.getSnapchatPackageInfo()?.versionName, SNAPCHAT_13_80_VERSION ) } + private fun logInfo(msg: String) = this@MediaDownloader.context.log.info("[MediaDownloader] $msg") + private fun logVerbose(msg: String) = this@MediaDownloader.context.log.verbose("[MediaDownloader] $msg") + private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@MediaDownloader.context.log.error("[MediaDownloader] $msg", e) else this@MediaDownloader.context.log.error("[MediaDownloader] $msg") + + @Volatile + private var batchTotalCount: Int = 0 + @Volatile + private var batchSuccessCount: Int = 0 + @Volatile + private var batchFailureCount: Int = 0 + @Volatile + private var initialBatchStoryIdentity: String? = null + fun provideDownloadManagerClient( mediaIdentifier: String, mediaAuthor: String, creationTimestamp: Long? = null, downloadSource: MediaDownloadSource, friendInfo: FriendInfo? = null, - forceAllowDuplicate: Boolean = false + forceAllowDuplicate: Boolean = false, + isBatch: Boolean = false ): DownloadManagerClient { + val modCtx = this@MediaDownloader.context val generatedHash = ( - if (!context.config.downloader.allowDuplicate.get() && !forceAllowDuplicate) mediaIdentifier + if (!modCtx.config.downloader.allowDuplicate.get() && !forceAllowDuplicate) mediaIdentifier else UUID.randomUUID().toString() ).longHashCode().absoluteValue.toString(16) val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) - - val downloadLogging by context.config.downloader.logging + val downloadLogging = modCtx.config.downloader.logging.get() val outputPath = createNewFilePath( - context.config, + modCtx.config, generatedHash.substring(0, generatedHash.length.coerceAtMost(8)), downloadSource, mediaAuthor, @@ -155,18 +143,16 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ) return DownloadManagerClient( - context = context, + context = modCtx, metadata = DownloadMetadata( mediaIdentifier = generatedHash, mediaAuthor = mediaAuthor, - downloadSource = downloadSource.translate(context.translation), + downloadSource = downloadSource.translate(modCtx.translation), iconUrl = iconUrl, outputPath = outputPath ), callback = object: DownloadCallback.Stub() { override fun onSuccess(outputFile: String) { - if (!downloadLogging.contains("success")) return - var finalOutputFile = outputFile runCatching { val file = java.io.File(outputFile) @@ -176,74 +162,61 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp input.read(buffer) buffer } + val fileType = FileType.fromByteArray(header) if (fileType.isVideo && !outputFile.endsWith(".mp4", ignoreCase = true)) { - val newPath = outputFile.removeSuffix(".dat") + ".mp4" + val newPath = outputFile.removeSuffix(".dat").removeSuffix(".tmp") + ".mp4" val newFile = java.io.File(newPath) if (file.renameTo(newFile)) { finalOutputFile = newPath - context.log.verbose("corrected video extension: $outputFile -> $newPath") + } else { + file.copyTo(newFile, overwrite = true) + file.delete() + finalOutputFile = newPath } } } + }.onFailure { logError("Post-Processing Logic Failed for $outputFile", it) } + + if (isBatch) { + batchSuccessCount++ + if (downloadLogging.contains("success")) { + modCtx.inAppOverlay.showStatusToast( + icon = Icons.Outlined.DownloadDone, + text = translations.format("batch_progress_toast", "current" to (batchSuccessCount + batchFailureCount).toString(), "total" to batchTotalCount.toString()), + durationMs = 1300 + ) + } + return } - context.log.verbose("onSuccess: outputFile=$finalOutputFile") - context.inAppOverlay.showStatusToast( - icon = Icons.Outlined.DownloadDone, - durationMs = 1300, - text = translations["content_saved_toast"].also { - if (context.isMainActivityPaused) { - context.shortToast(it) - } - }, - ) + if (downloadLogging.contains("success")) { + val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name) + if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300) + } } override fun onProgress(message: String) { - if (!downloadLogging.contains("progress")) return - context.log.verbose("onProgress: message=$message") - context.inAppOverlay.showStatusToast( - icon = Icons.Outlined.Info, - durationMs = 1300, - text = message, - ) - if (context.isMainActivityPaused) { - context.shortToast(message) - } + if (isBatch || !downloadLogging.contains("progress")) return + val toastText = message.ifBlank { translations["download_started_toast"] ?: "Started" } + if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300) } override fun onFailure(message: String, throwable: String?) { if (!downloadLogging.contains("failure")) return - context.log.verbose("onFailure: message=$message, throwable=$throwable") - if (context.isMainActivityPaused) { - context.shortToast(message) - } - throwable?.let { t -> - context.inAppOverlay.showStatusToast( - icon = Icons.Outlined.Error, - text = message + t.takeIf { it.isNotEmpty() }?.let { " $it" }.orEmpty(), - ) - return - } - - context.inAppOverlay.showStatusToast( - icon = Icons.Outlined.Warning, - durationMs = 1300, - text = message, - ) + val errorText = translations[if (message == "Failed to download") "failed_generic_toast" else message] ?: message + if (isBatch) { batchFailureCount++; return } + if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300) } } ) } - private fun ParamMap.getStorySnapIndex(): Int? = - this["snap_index_in_story"]?.toString()?.toIntOrNull() - ?: this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() - - private fun ParamMap.getStorySnapTotal(): Int? = - this["snap_story_length"]?.toString()?.toIntOrNull() - ?: this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() + private fun ParamMap.getStorySnapIndex(): Int? = this["snap_index_in_story"]?.toString()?.toIntOrNull() ?: this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() + private fun ParamMap.getStorySnapTotal(): Int? = this["snap_story_length"]?.toString()?.toIntOrNull() ?: this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() private fun isMultiSnapStory(paramMap: ParamMap): Boolean { if (paramMap.containsKey("MESSAGE_ID") || paramMap["SNAP_SOURCE"]?.toString() == "SINGLE_SNAP_STORY") return false @@ -252,936 +225,380 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp return total > 1 } - /* - * Download the last seen media - */ fun downloadLastOperaMediaAsync(allowDuplicate: Boolean) { if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return val paramMap = lastSeenMapParams!! val mediaInfoMap = lastSeenMediaInfoMap!! + val modCtx = this@MediaDownloader.context - if (isMultiSnapStory(paramMap) && context.config.downloader.storySnapListDownload.get()) { - context.runOnUiThread { - showStorySnapSelectionDialog(paramMap, mediaInfoMap, allowDuplicate) - } + if (isMultiSnapStory(paramMap) && modCtx.config.downloader.storySnapListDownload.get()) { + modCtx.runOnUiThread { showStorySnapSelectionDialog(paramMap, mediaInfoMap, allowDuplicate) } return } - context.executeAsync { - handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) - } + modCtx.coroutineScope.launch { handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) } } private fun showStorySnapSelectionDialog(paramMap: ParamMap, mediaInfoMap: Map, allowDuplicate: Boolean) { val totalCount = paramMap.getStorySnapTotal() ?: return val currentIndex = paramMap.getStorySnapIndex() ?: 0 - val tr = context.translation.getCategory("download_processor.story_snap_dialog") - val cancelStr = context.translation["button.cancel"] - val downloadStr = context.translation["button.download"] - context.runOnUiThread { - createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + val modCtx = this@MediaDownloader.context + val tr = modCtx.translation.getCategory("download_processor.story_snap_dialog") + val cancelStr = modCtx.translation["button.cancel"] ?: "Cancel" + val downloadStr = modCtx.translation["button.download"] ?: "Download" + + modCtx.runOnUiThread { + val mainActivity = modCtx.mainActivity ?: return@runOnUiThread + createComposeAlertDialog(mainActivity) { alertDialog -> PurrfectOverlayTheme { val selected = remember { mutableStateListOf().apply { add(currentIndex) } } - - PurrfectGlassCard( - title = tr["title"], - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp, max = 320.dp) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { + PurrfectGlassCard(title = tr["title"] ?: "Select", modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp, max = 320.dp).background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)).padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { itemsIndexed((0 until totalCount).toList()) { index, _ -> - val label = tr.format("snap_item", "index" to (index + 1).toString(), "total" to totalCount.toString()) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Checkbox( - checked = selected.contains(index), - onCheckedChange = { checked -> - if (checked) selected.add(index) else selected.remove(index) - }, - colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) - ) - Text( - label, - style = MaterialTheme.typography.bodyMedium, - color = PurrfectOverlayPalette.textPrimary - ) + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = selected.contains(index), onCheckedChange = { if (it) selected.add(index) else selected.remove(index) }, colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)) + Text(tr.format("snap_item", "index" to (index + 1).toString(), "total" to totalCount.toString()), style = MaterialTheme.typography.bodyMedium, color = PurrfectOverlayPalette.textPrimary) } } } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = selected.size == totalCount, - onCheckedChange = { checked -> - if (checked) { - selected.clear() - selected.addAll(0 until totalCount) - } else { - selected.clear() - } - }, - colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) - ) - Text( - tr["select_all"], - style = MaterialTheme.typography.bodyMedium, - color = PurrfectOverlayPalette.textPrimary - ) + Checkbox(checked = selected.size == totalCount, onCheckedChange = { if (it) { selected.clear(); selected.addAll(0 until totalCount) } else selected.clear() }, colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)) + Text(tr["select_all"] ?: "Select All", style = MaterialTheme.typography.bodyMedium, color = PurrfectOverlayPalette.textPrimary) } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - onClick = { alertDialog.dismiss() }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = PurrfectOverlayPalette.textPrimary) - ) { - Text(cancelStr) - } - Button( - onClick = { - if (!selected.contains(currentIndex)) return@Button - context.executeAsync { - runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) } - .onFailure { - context.log.error("Story download failed", it) - context.shortToast(translations["failed_generic_toast"]) - } - } - alertDialog.dismiss() - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary) - ) { - Text(downloadStr) - } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = { alertDialog.dismiss() }, modifier = Modifier.weight(1f), shape = RoundedCornerShape(14.dp)) { Text(cancelStr) } + Button(onClick = { if (selected.isNotEmpty()) { startBatchDownload(selected.sorted().toMutableList(), allowDuplicate); alertDialog.dismiss() } }, modifier = Modifier.weight(1f), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary)) { Text(downloadStr) } } } } } - }.apply { - window?.setBackgroundDrawableResource(android.R.color.transparent) - show() + }.apply { window?.setBackgroundDrawableResource(android.R.color.transparent); show() } + } + } + + private fun startBatchDownload(indices: MutableList, allowDuplicate: Boolean) { + if (indices.isEmpty()) return + val paramMap = lastSeenMapParams ?: return + val mediaInfoMap = lastSeenMediaInfoMap ?: return + val modCtx = this@MediaDownloader.context + + batchTotalCount = indices.size + batchSuccessCount = 0; batchFailureCount = 0 + pendingBatchDownloadIndices = indices + batchForceAllowDuplicate = allowDuplicate + initialBatchStoryIdentity = paramMap.getStoryIdentity() + + val currentIndex = paramMap.getStorySnapIndex() ?: 0 + val targetIndex = indices.first() + val totalCount = paramMap.getStorySnapTotal() + + if (currentIndex == targetIndex) { + modCtx.coroutineScope.launch { processNextBatchDownload(paramMap, mediaInfoMap) } + } else { + val jumped = modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount) + if (!jumped) { pendingBatchDownloadIndices = null; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } + } + } + + private suspend fun downloadSingleSnap(paramMap: ParamMap, mediaInfoMap: Map) { + runCatching { + handleOperaMedia(paramMap, mediaInfoMap, forceDownload = true, forceAllowDuplicate = batchForceAllowDuplicate, isBatch = true) + }.onFailure { + batchFailureCount++ + if (batchSuccessCount + batchFailureCount == batchTotalCount) flushPendingMergeAndComplete() + } + } + + private suspend fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map) { + val queue = pendingBatchDownloadIndices ?: return + if (queue.isEmpty()) return + val modCtx = this@MediaDownloader.context + + val currentIdentity = paramMap.getStoryIdentity() + if (initialBatchStoryIdentity != null && (currentIdentity == null || currentIdentity != initialBatchStoryIdentity)) { + flushPendingMergeAndComplete(); return + } + + val currentIndex = paramMap.getStorySnapIndex() ?: -1 + if (currentIndex != queue.first()) return + + queue.removeAt(0) + downloadSingleSnap(paramMap, mediaInfoMap) + + if (queue.isNotEmpty()) { + val totalCount = paramMap.getStorySnapTotal() + modCtx.runOnUiThread { + fun tryJump(retryCount: Int = 0) { + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + val jumped = runCatching { modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) }.getOrNull() == true + if (!jumped && retryCount < 1) tryJump(retryCount + 1) + else if (!jumped) { pendingBatchDownloadIndices = null; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } + }, if (retryCount == 0) 120L else 220L) + } + tryJump() } } } + private fun flushPendingMergeAndComplete() { + val modCtx = this@MediaDownloader.context + pendingBatchDownloadIndices = null + modCtx.shortToast(if (batchFailureCount == 0) translations["batch_download_complete_toast"] ?: "Batch Complete" else "Batch complete: $batchSuccessCount succeeded, $batchFailureCount failed") + } + fun showLastOperaDebugMediaInfo() { if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return - - context.runOnUiThread { + val modCtx = this@MediaDownloader.context + modCtx.runOnUiThread { + val mainActivity = modCtx.mainActivity ?: return@runOnUiThread val mediaInfoText = lastSeenMapParams?.concurrentHashMap?.map { (key, value) -> - val transformedValue = value.let { - if (it::class.java == PurrfectSnap.classCache.snapUUID) { - SnapUUID(it).toString() - } - it - } + val transformedValue = if (value != null && value::class.java == PurrfectSnap.classCache.snapUUID) SnapUUID(value).toString() else value "- $key: $transformedValue" }?.joinToString("\n") ?: "No media info found" - - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { setTitle("Debug Media Info") - setView(debugEditText(context, mediaInfoText)) - setNeutralButton("Copy") { _, _ -> - context.copyToClipboard(mediaInfoText) - } + setView(debugEditText(modCtx.androidContext, mediaInfoText)) + setNeutralButton("Copy") { _, _ -> modCtx.androidContext.copyToClipboard(mediaInfoText) } setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() } }.show() } } - private fun isSnapContentType(contentTypeId: Int): Boolean { - return when (ContentType.fromId(contentTypeId)) { - ContentType.SNAP, - ContentType.TINY_SNAP, - ContentType.EXTERNAL_MEDIA -> true - else -> false - } + private fun isSnapContentType(contentTypeId: Int): Boolean = when (ContentType.fromId(contentTypeId)) { + ContentType.SNAP, ContentType.TINY_SNAP, ContentType.EXTERNAL_MEDIA -> true + else -> false } private fun validateViewerMessageContext(messageContext: OperaViewerMessageContext): OperaViewerMessageContext? { - val message = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return null - if (message.clientConversationId != messageContext.conversationId) return null - if (!isSnapContentType(message.contentType)) return null + val modCtx = this@MediaDownloader.context + val message = modCtx.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return null + if (message.clientConversationId != messageContext.conversationId || !isSnapContentType(message.contentType)) return null return messageContext } private fun resolveLegacyViewerMessageContext(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? { - val parts = paramMap?.get("MESSAGE_ID") - ?.toString() - ?.split(':') - ?.takeIf { it.size == 3 } - ?: return null - - return OperaViewerMessageContext( - conversationId = parts[0], - clientMessageId = parts[2].toLongOrNull() ?: return null - ) + val parts = paramMap?.get("MESSAGE_ID")?.toString()?.split(':')?.takeIf { it.size == 3 } ?: return null + return OperaViewerMessageContext(conversationId = parts[0], clientMessageId = parts[2].toLongOrNull() ?: return null) } private fun parseViewerMessageContext(rawValue: String): OperaViewerMessageContext? { val parts = rawValue.split(':') if (parts.size < 3) return null - - val conversationId = parts.firstOrNull()?.takeIf { - runCatching { UUID.fromString(it) }.isSuccess - } ?: return null + val conversationId = parts.firstOrNull()?.takeIf { runCatching { UUID.fromString(it) }.isSuccess } ?: return null val clientMessageId = parts.lastOrNull()?.toLongOrNull() ?: return null - - return OperaViewerMessageContext( - conversationId = conversationId, - clientMessageId = clientMessageId - ) + return OperaViewerMessageContext(conversationId = conversationId, clientMessageId = clientMessageId) } fun resolveViewerMessageContextFromParamMap(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? { if (paramMap == null) return null if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext(paramMap) - - paramMap["MESSAGE_ID"]?.toString() - ?.let(::parseViewerMessageContext) - ?.let(::validateViewerMessageContext) - ?.let { return it } - - return paramMap.concurrentHashMap.values - .asSequence() - .mapNotNull { value -> - value?.toString()?.let(::parseViewerMessageContext) - } - .mapNotNull(::validateViewerMessageContext) - .firstOrNull() + paramMap["MESSAGE_ID"]?.toString()?.let(::parseViewerMessageContext)?.let(::validateViewerMessageContext)?.let { return it } + return paramMap.concurrentHashMap.values.asSequence().mapNotNull { it?.toString()?.let(::parseViewerMessageContext) }.mapNotNull(::validateViewerMessageContext).firstOrNull() } fun resolveCurrentSnapMessageContext(): OperaViewerMessageContext? { + val modCtx = this@MediaDownloader.context if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext() - - val messaging = context.feature(Messaging::class) + val messaging = modCtx.feature(Messaging::class) val currentConversationId = messaging.openedConversationUUID?.toString() val currentMessageId = messaging.lastFocusedMessageId.takeIf { it > 0L } - if (currentConversationId != null && currentMessageId != null) { - validateViewerMessageContext( - OperaViewerMessageContext( - conversationId = currentConversationId, - clientMessageId = currentMessageId - ) - )?.let { return it } + validateViewerMessageContext(OperaViewerMessageContext(conversationId = currentConversationId, clientMessageId = currentMessageId))?.let { return it } } - return resolveViewerMessageContextFromParamMap() } - private fun handleLocalReferences(path: String) = runBlocking { - Uri.parse(path).let { uri -> - if (uri.scheme == "file" || uri.scheme == null) { - return@let suspendCoroutine { continuation -> - context.httpServer.ensureServerStarted()?.let { server -> - val file = Paths.get(uri.path).toFile() - val url = server.putDownloadableContent(file.inputStream(), file.length()) - continuation.resumeWith(Result.success(url)) - } ?: run { - continuation.resumeWith(Result.failure(Exception("Failed to start http server"))) - } - } - } - path + private suspend fun handleLocalReferences(path: String): String { + val modCtx = this@MediaDownloader.context + val uri = Uri.parse(path) + if (uri.scheme == "http" || uri.scheme == "https") return path + return suspendCoroutine { continuation -> + modCtx.httpServer.ensureServerStarted()?.let { server -> + runCatching { + val file = java.io.File(uri.path ?: path) + if (!file.exists()) { continuation.resume(path); return@runCatching } + val url = server.putDownloadableContent(file.inputStream(), file.length()) + continuation.resume(url) + }.onFailure { continuation.resume(path) } + } ?: continuation.resume(path) } } - private fun downloadOperaMedia( - downloadManagerClient: DownloadManagerClient, - mediaInfoMap: Map, - paramMap: ParamMap - ) { + private suspend fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map, paramMap: ParamMap) { + val modCtx = this@MediaDownloader.context if (mediaInfoMap.isEmpty()) return - - // Story Snap Entry (images) paramMap["SNAP_ID"]?.toString()?.let { snapId -> - context.database.getStorySnapEntry(snapId)?.let { storySnapEntry -> - + modCtx.database.getStorySnapEntry(snapId)?.let { storySnapEntry -> downloadManagerClient.downloadSingleMedia( storySnapEntry.mediaUrl ?: throw Exception("Media URL not found"), DownloadMediaType.fromUri(Uri.parse(storySnapEntry.mediaUrl)), - (storySnapEntry.mediaKey to storySnapEntry.mediaIv) - .takeIf { it.first != null && it.second != null } - ?.let { (key, iv) -> MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false) } - ) - return + (storySnapEntry.mediaKey to storySnapEntry.mediaIv).takeIf { it.first != null && it.second != null }?.let { (k, i) -> MediaEncryptionKeyPair(k!!, i!!, urlSafe = false) } + ); return } } - - val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! - val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) - - // Overlay (if present) + val originalMediaRef = handleLocalReferences(mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.uri) mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay -> - val overlayReference = handleLocalReferences(overlay.uri) - + val overlayRef = handleLocalReferences(overlay.uri) downloadManagerClient.downloadMediaWithOverlay( - original = InputMedia( - originalMediaInfoReference, - DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - originalMediaInfo.encryption?.toKeyPair() - ), - overlay = InputMedia( - overlayReference, - DownloadMediaType.fromUri(Uri.parse(overlayReference)), - overlay.encryption?.toKeyPair(), - isOverlay = true - ) - ) - return + InputMedia(originalMediaRef, DownloadMediaType.fromUri(Uri.parse(originalMediaRef)), mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.encryption?.toKeyPair()), + InputMedia(overlayRef, DownloadMediaType.fromUri(Uri.parse(overlayRef)), overlay.encryption?.toKeyPair(), isOverlay = true) + ); return } - - // Single media (video/DASH) - downloadManagerClient.downloadSingleMedia( - originalMediaInfoReference, - DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - originalMediaInfo.encryption?.toKeyPair() - ) + downloadManagerClient.downloadSingleMedia(originalMediaRef, DownloadMediaType.fromUri(Uri.parse(originalMediaRef)), mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.encryption?.toKeyPair()) } fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean { - if (context.config.downloader.preventSelfAutoDownload.get() && databaseMessage.senderId == context.database.myUserId) return false + val modCtx = this@MediaDownloader.context + if (modCtx.config.downloader.preventSelfAutoDownload.get() && databaseMessage.senderId == modCtx.database.myUserId) return false return canUseRule(databaseMessage.clientConversationId!!) } - /** - * Handles the media from the opera viewer - * - * @param paramMap the parameters from the opera viewer - * @param mediaInfoMap the media info map - * @param forceDownload if the media should be downloaded - */ - private fun handleOperaMedia( - paramMap: ParamMap, - mediaInfoMap: Map, - forceDownload: Boolean, - forceAllowDuplicate: Boolean = false - ) { - - // ─── Messages ───────────────────────── - resolveViewerMessageContextFromParamMap(paramMap)?.takeIf { - forceDownload || shouldAutoDownload("friend_snaps") - }?.let { messageContext -> - val conversationMessage = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return@let - val conversationId = conversationMessage.clientConversationId!! - - if (!forceDownload && !canUseRule(conversationId)) return@let - - val senderId = conversationMessage.senderId!! - if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && - senderId == context.database.myUserId - ) return@let - - val author = context.database.getFriendInfo(senderId) ?: return@let - val authorUsername = author.usernameForSorting!! - val mediaId = paramMap["MEDIA_ID"]?.toString()?.substringAfter("-")?.substringBefore(".") ?: "" - - downloadOperaMedia( - provideDownloadManagerClient( - mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId", - mediaAuthor = authorUsername, - creationTimestamp = conversationMessage.creationTimestamp, - downloadSource = MediaDownloadSource.CHAT_MEDIA, - friendInfo = author, - forceAllowDuplicate = forceAllowDuplicate - ), - mediaInfoMap, - paramMap - ) + private suspend fun handleOperaMedia(paramMap: ParamMap, mediaInfoMap: Map, forceDownload: Boolean, forceAllowDuplicate: Boolean = false, isBatch: Boolean = false) { + val modCtx = this@MediaDownloader.context + resolveViewerMessageContextFromParamMap(paramMap)?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { messageContext -> + val msg = modCtx.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return@let + if (!forceDownload && (!canUseRule(msg.clientConversationId!!) || (modCtx.config.downloader.preventSelfAutoDownload.get() && msg.senderId == modCtx.database.myUserId))) return@let + val author = modCtx.database.getFriendInfo(msg.senderId!!) ?: return@let + downloadOperaMedia(provideDownloadManagerClient("${msg.clientConversationId}${msg.senderId}${msg.serverMessageId}", author.usernameForSorting!!, msg.creationTimestamp, MediaDownloadSource.CHAT_MEDIA, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) return } - - // ─── Private Friend Story ───────────────────────── - paramMap["PLAYLIST_V2_GROUP"]?.takeIf { - forceDownload || shouldAutoDownload("friend_stories") - }?.let { playlistGroup -> - val playlistGroupString = playlistGroup.toString() - - val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let { - if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null - } ?: if (playlistGroupString.contains("storyUserId=")) { - playlistGroupString.substringAfter("storyUserId=").substringBefore(",") - } else { - //story replies - val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" } - ?.invoke(playlistGroup)?.toString() - ?.split(":")?.getOrNull(2) ?: return@let - - val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let - val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let - conversationParticipants.firstOrNull { it != conversationMessage.senderId } - } - - val author = context.database.getFriendInfo( - if (storyUserId == null || storyUserId == "null") - context.database.myUserId - else storyUserId - ) ?: throw Exception("Friend not found in database") - val authorName = author.usernameForSorting!! - - if (!forceDownload) { - if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return - if (!canUseRule(author.userId!!)) return - } - - downloadOperaMedia( - provideDownloadManagerClient( - mediaIdentifier = paramMap["MEDIA_ID"].toString(), - mediaAuthor = authorName, - creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=") - ?.substringBefore(",")?.toLongOrNull(), - downloadSource = MediaDownloadSource.STORY, - friendInfo = author, - forceAllowDuplicate = forceAllowDuplicate - ), - mediaInfoMap, - paramMap - ) + paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let { + val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("userId=")?.substringBefore(",") + val author = modCtx.database.getFriendInfo(storyUserId ?: modCtx.database.myUserId) ?: return@let + if (!forceDownload && ((modCtx.config.downloader.preventSelfAutoDownload.get() && author.userId == modCtx.database.myUserId) || !canUseRule(author.userId!!))) return@let + downloadOperaMedia(provideDownloadManagerClient(paramMap["MEDIA_ID"].toString(), author.usernameForSorting!!, null, MediaDownloadSource.STORY, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) return } - - // ─── Public Stories / Spotlight ─────────────────── val snapSource = paramMap["SNAP_SOURCE"].toString() - - //spotlight if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || shouldAutoDownload("spotlight"))) { - downloadOperaMedia(provideDownloadManagerClient( - mediaIdentifier = paramMap["SNAP_ID"].toString(), - downloadSource = MediaDownloadSource.SPOTLIGHT, - mediaAuthor = paramMap["CREATOR_DISPLAY_NAME"].toString(), - creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), - forceAllowDuplicate = forceAllowDuplicate, - ), mediaInfoMap, paramMap) - return + downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), paramMap["CREATOR_DISPLAY_NAME"].toString(), null, MediaDownloadSource.SPOTLIGHT, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap); return } - - //stories with mpeg dash media - if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { - val storyName = paramMap["STORY_NAME"].toString().sanitizeForPath() - //get the position of the media in the playlist and the duration - val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) - val snapChapterList = LongformVideoPlaylistItem(paramMap["LONGFORM_VIDEO_PLAYLIST_ITEM"]!!).chapters - val currentChapterIndex = snapChapterList.indexOfFirst { it.snapId == snapItem.snapId } - - if (snapChapterList.isEmpty()) { - context.shortToast(translations["dash_no_chapter"]) - return - } - - fun prettyPrintTime(time: Long): String { - val seconds = time / 1000 - val minutes = seconds / 60 - val hours = minutes / 60 - return "${(hours % 24).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}" - } - - val playlistUrl = paramMap["MEDIA_ID"].toString().let { - val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net")) - - urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex -> - it.substring(validIndex) - } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" - } - - context.runOnUiThread { - val tr = context.translation.getCategory("download_processor.dash_dialog") - val chapters = snapChapterList.mapIndexed { index, snapChapter -> - val nextChapter = snapChapterList.getOrNull(index + 1) - val duration = nextChapter?.startTimeMs?.minus(snapChapter.startTimeMs) - SnapChapterInfo(snapChapter.startTimeMs, duration) - } - val cancelStr = context.translation["button.cancel"] - val downloadStr = context.translation["button.download"] - - createComposeAlertDialog(context.mainActivity!!) { alertDialog -> - PurrfectOverlayTheme { - val selected = remember { mutableStateListOf().apply { add(currentChapterIndex) } } - PurrfectGlassCard( - title = tr["title"], - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp, max = 320.dp) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - itemsIndexed(chapters) { index, item -> - val label = tr.format("snap_text", "from" to prettyPrintTime(item.offset), "to" to prettyPrintTime(item.offset + (item.duration ?: 0))) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Checkbox( - checked = selected.contains(index), - onCheckedChange = { checked -> - if (checked) selected.add(index) else selected.remove(index) - }, - colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) - ) - Text( - label, - style = MaterialTheme.typography.bodyMedium, - color = PurrfectOverlayPalette.textPrimary - ) - } - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = selected.size == chapters.size, - onCheckedChange = { checked -> - if (checked) { - selected.clear() - selected.addAll(0 until chapters.size) - } else { - selected.clear() - } - }, - colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) - ) - Text( - tr["download_all"] ?: "Select All", - style = MaterialTheme.typography.bodyMedium, - color = PurrfectOverlayPalette.textPrimary - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - onClick = { alertDialog.dismiss() }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = PurrfectOverlayPalette.textPrimary) - ) { - Text(cancelStr) - } - Button( - onClick = { - val groups = mutableListOf>() - var lastIdx = -1 - chapters.forEachIndexed { index, info -> - if (selected.contains(index)) { - if (lastIdx == -1 || index != lastIdx + 1) groups.add(mutableListOf()) - groups.last().add(info) - lastIdx = index - } - } - groups.forEach { group -> - val first = group.first() - val last = group.last() - val duration = if (first == last) first.duration else last.duration?.let { last.offset - first.offset + it } - provideDownloadManagerClient("${paramMap["STORY_ID"]}-${first.offset}", storyName, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate) - .downloadDashMedia(playlistUrl, first.offset.plus(100), duration) - } - alertDialog.dismiss() - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary) - ) { - Text(downloadStr) - } - } - } - } - } - }.show() - } - } - if (!forceDownload && !shouldAutoDownload("public_stories")) return - - //public stories - val author = ( - paramMap["USER_ID"]?.let { context.database.getFriendInfo(it.toString())?.mutableUsername } // only for following users - ?: paramMap["USERNAME"]?.toString()?.takeIf { - it.contains("value=") - }?.substringAfter("value=")?.substringBefore(")")?.substringBefore(",") - ?: paramMap["CONTEXT_USER_IDENTITY"]?.toString()?.takeIf { - it.contains("username=") - }?.substringAfter("username=")?.substringBefore(",") - // fallback display name - ?: paramMap["USER_DISPLAY_NAME"]?.toString()?.takeIf { it.isNotEmpty() } - ?: paramMap["TIME_STAMP"]?.toString() - ?: "unknown" - ).sanitizeForPath() - - downloadOperaMedia(provideDownloadManagerClient( - mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaAuthor = author, - downloadSource = MediaDownloadSource.PUBLIC_STORY, - creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), - forceAllowDuplicate = forceAllowDuplicate, - ), mediaInfoMap, paramMap) + val author = (paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername } ?: paramMap["USERNAME"]?.toString()?.substringAfter("value=")?.substringBefore(")") ?: "unknown").sanitizeForPath() + downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), author, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) } - private fun shouldAutoDownload(keyFilter: String? = null): Boolean { - val options by context.config.downloader.autoDownloadSources - return options.any { keyFilter == null || it.contains(keyFilter, true) } - } + private fun shouldAutoDownload(keyFilter: String? = null): Boolean = this@MediaDownloader.context.config.downloader.autoDownloadSources.get().any { keyFilter == null || it.contains(keyFilter, true) } override fun init() { + val modCtx = this@MediaDownloader.context if (getRuleState() == null) return onNextActivityCreate { - context.mappings.useMapper(OperaPageViewControllerMapper::class) { + modCtx.mappings.useMapper(OperaPageViewControllerMapper::class) { arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> - classReference.get()?.hook( - methodName.get() ?: return@forEach, - HookStage.AFTER - ) onOperaViewStateCallback@{ param -> + classReference.get()?.hook(methodName.get() ?: return@forEach, HookStage.AFTER) { param -> val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString() - - if (viewState != "FULLY_DISPLAYED") { - return@onOperaViewStateCallback - } - + if (viewState != "FULLY_DISPLAYED" && viewState != "DISPLAYED") return@hook val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> - val layerParamMaps = operaLayerList - .asSequence() - .mapNotNull { layerObj -> - layerObj?.let { runCatching { Layer(it).paramMap }.getOrNull() } - } - .toList() - val firstLayerParamMap = layerParamMaps.firstOrNull() - val mediaParamMap: ParamMap = if (useModernOperaViewerContext) { - ( - // Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work. - layerParamMaps.firstOrNull { - it.containsKey("MESSAGE_ID") && - (it.containsKey("image_media_info") || it.containsKey("video_media_info_list")) - } - ?: firstLayerParamMap?.takeIf { - it.containsKey("image_media_info") || it.containsKey("video_media_info_list") - } - ?: layerParamMaps.firstOrNull { - it.containsKey("image_media_info") || it.containsKey("video_media_info_list") - } - ) - } else { - layerParamMaps.firstOrNull { - it.containsKey("image_media_info") || it.containsKey("video_media_info_list") - } - } ?: return@onOperaViewStateCallback - + val layerParamMaps = operaLayerList.mapNotNull { l -> l?.let { runCatching { Layer(it).paramMap }.getOrNull() } } + val mediaParamMap = if (useModernOperaViewerContext) { + layerParamMaps.firstOrNull { it.containsKey("MESSAGE_ID") && (it.containsKey("image_media_info") || it.containsKey("video_media_info_list")) } + ?: layerParamMaps.firstOrNull { it.containsKey("image_media_info") || it.containsKey("video_media_info_list") } + } else layerParamMaps.firstOrNull { it.containsKey("image_media_info") || it.containsKey("video_media_info_list") } ?: return@hook val mediaInfoMap = mutableMapOf() - val isVideo = mediaParamMap.containsKey("video_media_info_list") - - mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( - (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! - ) - - if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) { - mediaInfoMap[SplitMediaAssetType.OVERLAY] = - MediaInfo(mediaParamMap["overlay_image_media_info"]!!) - } - - val shouldAutoDownload = shouldAutoDownload() - - if (shouldAutoDownload && lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri == mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri) return@onOperaViewStateCallback - - lastSeenMapParams = mediaParamMap - lastSeenMediaInfoMap = mediaInfoMap - - if (!shouldAutoDownload) { - return@onOperaViewStateCallback - } - - context.executeAsync { - runCatching { - handleOperaMedia(mediaParamMap, mediaInfoMap, false) - }.onFailure { - context.log.error("Failed to handle opera media", it) - context.longToast(it.message) - } - } + val isVideo = mediaParamMap!!.containsKey("video_media_info_list") + mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo(mediaParamMap[if (isVideo) "video_media_info_list" else "image_media_info"]!!) + if (modCtx.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) mediaInfoMap[SplitMediaAssetType.OVERLAY] = MediaInfo(mediaParamMap["overlay_image_media_info"]!!) + if (shouldAutoDownload() && lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri == mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri) return@hook + lastSeenMapParams = mediaParamMap; lastSeenMediaInfoMap = mediaInfoMap + if (pendingBatchDownloadIndices != null) { modCtx.coroutineScope.launch { processNextBatchDownload(mediaParamMap, mediaInfoMap) }; return@hook } + if (!shouldAutoDownload()) return@hook + modCtx.coroutineScope.launch { runCatching { handleOperaMedia(mediaParamMap, mediaInfoMap, false) } } } } } } } - private fun downloadMessageAttachments( - friendInfo: FriendInfo, - message: ConversationMessage, - authorName: String, - attachments: List, - forceAllowDuplicate: Boolean = false - ) { - attachments.forEach { attachment -> - runCatching { - provideDownloadManagerClient( - mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}${attachment.mediaUniqueId}", - downloadSource = MediaDownloadSource.CHAT_MEDIA, - mediaAuthor = authorName, - friendInfo = friendInfo, - forceAllowDuplicate = forceAllowDuplicate, - creationTimestamp = message.creationTimestamp, - ).apply { - downloadInputMedias( - arrayOf(attachment.createInputMedia()!!) - ) - } - }.onFailure { - context.longToast(translations["failed_generic_toast"]) - context.log.error("Failed to download", it) - } - } - } - - private fun DecodedAttachment.getInfo(): String { - return "${translations["attachment_type.${type.key}"]} ${attachmentInfo?.resolution?.let { "(${it.first}x${it.second})" } ?: ""}" - } - - @SuppressLint("SetTextI18n") - private fun previewAttachment( - attachment: DecodedAttachment - ) { - var previewBitmap: Bitmap? = null - val previewCoroutine = context.coroutineScope.launch { - runCatching { - attachment.openStream { attachmentStream, _ -> - val downloadedMediaList = mutableMapOf() - - MediaDownloaderHelper.getSplitElements(attachmentStream!!) { - type, inputStream -> - downloadedMediaList[type] = inputStream.readBytes() - } - - val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@openStream - val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] - - var bitmap = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) - ?: throw Exception("preview is null") - - overlay?.also { - bitmap = PreviewUtils.mergeBitmapOverlay(bitmap, BitmapFactory.decodeByteArray(it, 0, it.size)) - } - - previewBitmap = bitmap - } - }.onFailure { - context.shortToast(translations["failed_to_create_preview_toast"]) - context.log.error("Failed to create preview", it) - } - } - - with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) { - val viewGroup = LinearLayout(context).apply { - layoutParams = MarginLayoutParams( - MarginLayoutParams.MATCH_PARENT, - MarginLayoutParams.MATCH_PARENT - ) - gravity = Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL - addView(ProgressBar(context).apply { - isIndeterminate = true - }) - } - - setOnDismissListener { - previewCoroutine.cancel() - } - - previewCoroutine.invokeOnCompletion { cause -> - if (previewCoroutine.isCancelled) return@invokeOnCompletion - runOnUiThread { - viewGroup.removeAllViews() - if (cause != null) { - viewGroup.addView(TextView(context).apply { - text = - translations["failed_to_create_preview_toast"] + "\n" + cause.message - setPadding(30, 30, 30, 30) - }) - return@runOnUiThread - } - - viewGroup.addView(ImageView(context).apply { - setImageBitmap(previewBitmap) - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT - ) - adjustViewBounds = true - }) - } - } - - runOnUiThread { - show().apply { - setContentView(viewGroup) - window?.setLayout( - context.resources.displayMetrics.widthPixels, - context.resources.displayMetrics.heightPixels - ) - } - } - } - } - - @SuppressLint("SetTextI18n") - fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false, forceDownloadFirst: Boolean = false) { - val messageLogger = context.feature(MessageLogger::class) - val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database") - - val friendInfo = context.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found in database") - val authorName = friendInfo.usernameForSorting!! - - val decodedAttachments = ( - messageLogger.takeIf { it.isEnabled }?.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.let { - MessageDecoder.decode(it.getAsJsonObject("mMessageContent")) - } ?: MessageDecoder.decode( - protoReader = ProtoReader(message.messageContent!!) - ).toMutableList().apply { - val quotedMessage = message.quotedServerMessageId?.takeIf { it > 0 }?.let { quotedMessageId -> - context.database.getConversationServerMessage(message.clientConversationId!!, quotedMessageId) - } ?: return@apply - addAll(0, MessageDecoder.decode( - protoReader = ProtoReader(quotedMessage.messageContent ?: return@apply) - )) - } - ).toMutableList() - - context.feature(Messaging::class).conversationManager?.takeIf { - decodedAttachments.isEmpty() - }?.also { conversationManager -> - runBlocking { - suspendCoroutine { continuation -> - conversationManager.fetchMessage(message.clientConversationId!!, message.clientMessageId.toLong(), onSuccess = { message -> - decodedAttachments.addAll(MessageDecoder.decode(message.messageContent!!)) - continuation.resumeWith(Result.success(Unit)) - }, onError = { - continuation.resumeWith(Result.success(Unit)) - }) - } - } - } - - if (decodedAttachments.isEmpty()) { - context.shortToast(translations["no_attachments_toast"]) - return - } - + suspend fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false, forceDownloadFirst: Boolean = false) { + val modCtx = this@MediaDownloader.context + val message = modCtx.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found") + val friendInfo = modCtx.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found") + val decodedAttachments = MessageDecoder.decode(ProtoReader(message.messageContent!!)).toMutableList() + if (decodedAttachments.isEmpty()) { modCtx.shortToast(translations["no_attachments_toast"] ?: "No Attachments"); return } + if (!isPreview) { - if (forceDownloadFirst || - decodedAttachments.size == 1 || - context.isMainActivityPaused - ) { - downloadMessageAttachments(friendInfo, message, authorName, - listOf(decodedAttachments.first()), - forceAllowDuplicate = forceAllowDuplicate - ) - return - } - - runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { - val selectedAttachments = mutableListOf().apply { - addAll(decodedAttachments.indices) - } - setMultiChoiceItems( - decodedAttachments.mapIndexed { index, decodedAttachment -> - "${index + 1}: ${decodedAttachment.getInfo()}" - }.toTypedArray(), - decodedAttachments.map { true }.toBooleanArray() - ) { _, which, isChecked -> - if (isChecked) { - selectedAttachments.add(which) - } else if (selectedAttachments.contains(which)) { - selectedAttachments.remove(which) - } - } - setTitle(translations["select_attachments_title"]) - setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } - setPositiveButton(this@MediaDownloader.context.translation["button.download"]) { _, _ -> - downloadMessageAttachments(friendInfo, message, authorName, selectedAttachments.map { decodedAttachments[it] }, - forceAllowDuplicate = forceAllowDuplicate - ) - } - }.show() + if (forceDownloadFirst || decodedAttachments.size == 1 || modCtx.isMainActivityPaused) { + downloadMessageAttachments(friendInfo, message, friendInfo.usernameForSorting!!, listOf(decodedAttachments.first()), forceAllowDuplicate) + } else { + withContext(Dispatchers.Main) { showAttachmentSelectionDialog(friendInfo, message, decodedAttachments, forceAllowDuplicate) } } return } - - if (decodedAttachments.size == 1) { - previewAttachment(decodedAttachments.first()) - return - } - - runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { - var selectedAttachment = 0 - setSingleChoiceItems( - decodedAttachments.mapIndexed { index, decodedAttachment -> "${index + 1}: ${decodedAttachment.getInfo()}" }.toTypedArray(), - 0 - ) { _, which -> - selectedAttachment = which - } - setTitle(translations["select_attachments_title"]) - setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } - setPositiveButton(this@MediaDownloader.context.translation["chat_action_menu.preview_button"]) { _, _ -> - previewAttachment(decodedAttachments[selectedAttachment]) - } + + if (decodedAttachments.size == 1) { previewAttachment(decodedAttachments.first()); return } + + withContext(Dispatchers.Main) { + val mainActivity = modCtx.mainActivity ?: return@withContext + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + var selected = 0 + setSingleChoiceItems(decodedAttachments.mapIndexed { i, a -> "${i + 1}: ${translations["attachment_type.${a.type.key}"] ?: a.type.key}" }.toTypedArray(), 0) { _, w -> selected = w } + setPositiveButton(modCtx.translation["chat_action_menu.preview_button"] ?: "Preview") { _, _ -> previewAttachment(decodedAttachments[selected]) } }.show() } } - fun downloadProfilePicture(url: String, author: String) { - provideDownloadManagerClient( - mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""), - mediaAuthor = author, - downloadSource = MediaDownloadSource.PROFILE_PICTURE - ).downloadSingleMedia( - url, - DownloadMediaType.REMOTE_MEDIA - ) - } - - /** - * Called when a message is focused in chat - */ - fun onMessageActionMenu(isPreviewMode: Boolean, forceAllowDuplicate: Boolean = false) { - val messaging = context.feature(Messaging::class) - if (messaging.openedConversationUUID == null) return - - context.executeAsync { - downloadMessageId(messaging.lastFocusedMessageId, forceAllowDuplicate, isPreviewMode) + private fun downloadMessageAttachments(f: FriendInfo, m: ConversationMessage, author: String, attachments: List, forceDup: Boolean) { + attachments.forEach { a -> + runCatching { provideDownloadManagerClient("${m.clientConversationId}${m.senderId}${m.serverMessageId}", author, m.creationTimestamp, MediaDownloadSource.CHAT_MEDIA, f, forceDup).downloadInputMedias(arrayOf(a.createInputMedia()!!)) } } } + + private fun showAttachmentSelectionDialog(friendInfo: FriendInfo, message: ConversationMessage, attachments: List, forceAllowDuplicate: Boolean) { + val modCtx = this@MediaDownloader.context + val mainActivity = modCtx.mainActivity ?: return + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + val selected = mutableListOf().apply { addAll(attachments.indices) } + setMultiChoiceItems(attachments.mapIndexed { i, a -> "${i + 1}: ${translations["attachment_type.${a.type.key}"] ?: a.type.key}" }.toTypedArray(), attachments.map { true }.toBooleanArray()) { _, which, isChecked -> if (isChecked) selected.add(which) else selected.remove(which) } + setPositiveButton(modCtx.translation["button.download"] ?: "Download") { _, _ -> downloadMessageAttachments(friendInfo, message, friendInfo.usernameForSorting!!, selected.map { attachments[it] }, forceAllowDuplicate) } + }.show() + } + + @SuppressLint("SetTextI18n") + private fun previewAttachment(attachment: DecodedAttachment) { + val modCtx = this@MediaDownloader.context + var previewBitmap: Bitmap? = null + val previewCoroutine = modCtx.coroutineScope.launch { + runCatching { + attachment.openStream { attachmentStream, _ -> + val downloadedMediaList = mutableMapOf() + MediaDownloaderHelper.getSplitElements(attachmentStream!!) { type, inputStream -> downloadedMediaList[type] = inputStream.readBytes() } + val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@openStream + val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] + var bitmap = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) ?: throw Exception("preview is null") + overlay?.also { bitmap = PreviewUtils.mergeBitmapOverlay(bitmap, BitmapFactory.decodeByteArray(it, 0, it.size)) } + previewBitmap = bitmap + } + } + } + modCtx.runOnUiThread { + val mainActivity = modCtx.mainActivity ?: return@runOnUiThread + val viewGroup = LinearLayout(modCtx.androidContext).apply { gravity = Gravity.CENTER; addView(ProgressBar(modCtx.androidContext).apply { isIndeterminate = true }) } + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + setOnDismissListener { previewCoroutine.cancel() } + previewCoroutine.invokeOnCompletion { cause -> + modCtx.runOnUiThread { + viewGroup.removeAllViews() + if (cause != null) { viewGroup.addView(TextView(modCtx.androidContext).apply { text = "Failed to create preview"; setPadding(30, 30, 30, 30) }); return@runOnUiThread } + viewGroup.addView(ImageView(modCtx.androidContext).apply { setImageBitmap(previewBitmap); adjustViewBounds = true }) + } + } + val dialog = show() + dialog.setContentView(viewGroup) + dialog.window?.setLayout(modCtx.androidContext.resources.displayMetrics.widthPixels, modCtx.androidContext.resources.displayMetrics.heightPixels) + } + } + } + + fun downloadProfilePicture(url: String, author: String) { + provideDownloadManagerClient(url.hashCode().toString(16).replaceFirst("-", ""), author, null, MediaDownloadSource.PROFILE_PICTURE).downloadSingleMedia(url, DownloadMediaType.REMOTE_MEDIA) + } + + fun onMessageActionMenu(isPreviewMode: Boolean, forceAllowDuplicate: Boolean = false) { + val modCtx = this@MediaDownloader.context + val messaging = modCtx.feature(Messaging::class) + if (messaging.openedConversationUUID == null) return + modCtx.coroutineScope.launch { downloadMessageId(messaging.lastFocusedMessageId, forceAllowDuplicate, isPreviewMode) } + } } 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 d19f9f52..e2837409 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 @@ -9,7 +9,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build @@ -18,23 +17,22 @@ import androidx.core.content.edit import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import me.eternal.purrfectsnap.bridge.AutoOpenInterface -import me.eternal.purrfectsnap.common.BuildConfig +import me.eternal.purrfectsnap.common.config.PropertyValue import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.MessageState import me.eternal.purrfectsnap.common.data.MessageUpdate import me.eternal.purrfectsnap.common.data.MessagingRuleType import me.eternal.purrfectsnap.core.event.events.impl.BuildMessageEvent -import me.eternal.purrfectsnap.core.wrapper.impl.Message import me.eternal.purrfectsnap.core.features.MessagingRuleFeature import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging -import me.eternal.purrfectsnap.core.features.impl.tweaks.PerformanceMode 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 java.util.* -import java.util.Objects import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -42,400 +40,326 @@ import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume import kotlin.random.Random +/** + * AutoOpenSnaps: High-performance engine with real-time diagnostics. + * Optimized for 20+ snaps/s with accurate stats and background resilience. + */ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) { companion object { const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_PAUSE_RESUME" const val ACTION_CLEAR_QUEUE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_CLEAR_QUEUE" + const val ACTION_STOP_ENGINE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_STOP_ENGINE" + private const val STATUS_NOTIFICATION_ID = 54321 private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN" private const val PREF_TOTAL_OPENED = "auto_open_total_opened" private const val PREF_SESSION_START = "auto_open_session_start" - private const val PREF_SAVED_QUEUE = "auto_open_saved_queue" + + private const val LAZY_SAVE_INTERVAL_MS = 600_000L } private val gson = Gson() private val isPaused = AtomicBoolean(false) - private val totalProcessed = AtomicInteger(0) - private val sessionProcessed = AtomicInteger(0) + private val engineActive = AtomicBoolean(true) + private val totalProcessed = AtomicInteger(0) + private val sessionProcessed = AtomicInteger(0) private val sessionStartTime = AtomicLong(System.currentTimeMillis()) - private val totalPausedDuration = AtomicLong(0) - private var lastPausedAt = AtomicLong(0) private val averageProcessingTime = AtomicLong(800) - private val hasBeenActive = AtomicBoolean(false) - private val isScreenOn = AtomicBoolean(true) + private val lastSnapProcessedAt = AtomicLong(0) + + private val snapChannel = Channel(Channel.UNLIMITED) + private val openedSnapsIds = ConcurrentHashMap.newKeySet() + private val queuedSnaps = LinkedList() + private var engineJob: Job? = null + private val engineDispatcher = Dispatchers.Default.limitedParallelism(1) - private val snapQueue = MutableSharedFlow(extraBufferCapacity = 100) - private val openedSnaps = ConcurrentHashMap.newKeySet() - private val queuedSnaps = mutableListOf() - private val deadLetterQueue = mutableListOf() + private val autoOpenConfig by lazy { this@AutoOpenSnaps.context.config.messaging.autoOpenSnaps } + private val notificationManager by lazy { this@AutoOpenSnaps.context.androidContext.getSystemService(NotificationManager::class.java) } + private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) } + private val messaging by lazy { this@AutoOpenSnaps.context.feature(Messaging::class) } + private var wakeLock: PowerManager.WakeLock? = null - private val metadataCache = Collections.synchronizedMap(object : LinkedHashMap() { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 500 - }) - - private val config by lazy { context.config.messaging.autoOpenSnaps } - private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java) } - private val prefs by lazy { context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) } - - private var lastConversationId: String? = null private var currentStatusText = "Monitoring..." private var currentSpeedText = "Full Speed" - private var isCurrentlyWaiting = false - private var wakeLock: PowerManager.WakeLock? = null - private var wakeLockCooldownJob: Job? = null - private var lastQueueActivity = System.currentTimeMillis() - - private val lastNotificationUpdate = AtomicLong(0) + private var lastNotificationUpdate = 0L private val notificationUpdateDelay = 1000L private val pendingNotificationUpdate = AtomicBoolean(false) private val snapTimestamps = LinkedList() + private var lastConversationId: String? = null private val isSaving = AtomicBoolean(false) private val needsSaving = AtomicBoolean(false) private var isThermalThrottled = false private var lastThermalThrottleAt = 0L - private fun cancelStatusNotification() { - runCatching { notificationManager.cancel(STATUS_NOTIFICATION_ID) } - } - - data class SnapQueueItem( - val conversationId: String, - val messageId: Long, - val serverMessageId: Long?, - val senderId: String, - var senderName: String = "Pending...", - var conversationType: String = "Processing", - val contentType: String, - val timestamp: Long = System.currentTimeMillis() - ) - - private val autoOpenInterface = object : AutoOpenInterface.Stub() { - override fun getProcessedCount(): Int = sessionProcessed.get() - override fun getQueueItems(): List = synchronized(queuedSnaps) { queuedSnaps.map { gson.toJson(it) } } - override fun reset() { clearInternalState() } - } - - private fun clearInternalState() { - sessionProcessed.set(0) - totalProcessed.set(0) - totalPausedDuration.set(0) - lastPausedAt.set(0) - sessionStartTime.set(System.currentTimeMillis()) - synchronized(queuedSnaps) { queuedSnaps.clear() } - synchronized(deadLetterQueue) { deadLetterQueue.clear() } - openedSnaps.clear() - - prefs.edit() - .putLong(PREF_SESSION_START, System.currentTimeMillis()) - .remove(PREF_SAVED_QUEUE) - .remove(PREF_TOTAL_OPENED) - .apply() - - updateStatusNotification(force = true) - } - - fun getSnapMetadata(clientMessageId: Long): SnapQueueItem? = synchronized(queuedSnaps) { queuedSnaps.find { it.messageId == clientMessageId } } - - fun getInterface(): AutoOpenInterface = autoOpenInterface - - private val actionReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - ACTION_PAUSE_RESUME -> { - val paused = !isPaused.get() - isPaused.set(paused) - if (paused) lastPausedAt.set(System.currentTimeMillis()) - else { - if (lastPausedAt.get() > 0) totalPausedDuration.addAndGet(System.currentTimeMillis() - lastPausedAt.get()) - snapQueue.tryEmit(System.currentTimeMillis()) - } - updateStatusNotification(force = true) - } - ACTION_CLEAR_QUEUE -> clearInternalState() - Intent.ACTION_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) } - Intent.ACTION_SCREEN_OFF -> isScreenOn.set(false) - } - } - } - - override fun init() { - val messaging = context.feature(Messaging::class) - restorePersistence() - hasBeenActive.set(config.globalState == true) - - if (config.allowRunningInBackground.get()) { - acquireWakeLock() - findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply { - hook("appStateChanged", HookStage.BEFORE) { param -> - if (config.allowRunningInBackground.get()) { - val state = param.arg(0).toString() - if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null) - } - } - hookConstructor(HookStage.AFTER) { param -> - methods.firstOrNull { it.name == "appStateChanged" }?.let { method -> - val enumClass = method.parameterTypes[0] - val activeState = enumClass.enumConstants?.firstOrNull { it.toString() == "ACTIVE" || it.toString() == "FOREGROUND" } - if (activeState != null) method.invoke(param.thisObject(), activeState) - } - } - } - findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply { - hook("onAppForegrounded", HookStage.BEFORE) { param -> if (config.allowRunningInBackground.get()) param.setResult(null) } - hook("onAppBackgrounded", HookStage.BEFORE) { param -> if (config.allowRunningInBackground.get()) param.setResult(null) } - } - } - - createNotificationChannels() - val filter = IntentFilter().apply { - addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(Intent.ACTION_BATTERY_CHANGED); addAction(Intent.ACTION_SCREEN_ON); addAction(Intent.ACTION_SCREEN_OFF) - } - - val batteryReceiver = object : BroadcastReceiver() { - override fun onReceive(ctx: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_BATTERY_CHANGED && config.thermalProtection.get()) { - val temp = intent.getIntExtra("temperature", 0) / 10f - if (temp >= 40f && !isThermalThrottled) { - isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis() - } else if (isThermalThrottled && temp <= 36f && System.currentTimeMillis() - lastThermalThrottleAt > 600000) { - isThermalThrottled = false - } - } - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - context.androidContext.registerReceiver(batteryReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.androidContext.registerReceiver(actionReceiver, filter) - context.androidContext.registerReceiver(batteryReceiver, filter) - } - - if (synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) snapQueue.tryEmit(System.currentTimeMillis()) - - // Watchdog Loop - context.coroutineScope.launch(Dispatchers.Default) { - while (isActive) { - if (config.globalState != true) { shutdownFeature(); break } - val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size } - - if (remainingCount > 0) { - lastQueueActivity = System.currentTimeMillis(); acquireWakeLock() - if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis()) - } else { - if (!isPaused.get() && System.currentTimeMillis() - lastQueueActivity > 300000) { - val revived = synchronized(deadLetterQueue) { if (deadLetterQueue.isNotEmpty()) deadLetterQueue.removeAt(0) else null } - if (revived != null) { synchronized(queuedSnaps) { queuedSnaps.add(revived) }; snapQueue.tryEmit(System.currentTimeMillis()) } - } - if (System.currentTimeMillis() - lastQueueActivity > 300000) { - startWakeLockCooldown() - } - } - updateStatusNotification() - delay(5000) - } - } - - // Processing Loop - context.coroutineScope.launch(Dispatchers.Default) { - snapQueue.collect { - if (isPaused.get() || config.globalState != true) return@collect - while (isActive && config.globalState == true) { - val item = synchronized(queuedSnaps) { if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null } ?: break - - var resourceWaiting = true - while (resourceWaiting) { - if (config.globalState != true || isPaused.get()) break - val isWifi = isWifiConnected() - val isIdle = isDeviceIdle() - val onlyIdle = config.onlyWhenIdle.get() - val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false - - when { - config.onlyOnWifi.get() && !isWifi -> { - currentStatusText = "Waiting for WiFi..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000) - } - onlyIdle && !isIdle && !inSleepWindow -> { - currentStatusText = "Waiting for idle..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000) - } - else -> { - resourceWaiting = false; - val thermalActive = config.thermalProtection.get() && isThermalThrottled - currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed" - } - } - if (resourceWaiting) updateStatusNotification() - } - - if (isPaused.get() || config.globalState != true) { synchronized(queuedSnaps) { queuedSnaps.add(0, item) }; continue } - isCurrentlyWaiting = false - - // TIMING: 40ms switch - if (lastConversationId != null && lastConversationId != item.conversationId) { delay(40) } - lastConversationId = item.conversationId - currentStatusText = "Active"; updateStatusNotification() - - var success = false - val startTime = System.currentTimeMillis() - var currentRetryDelay = config.retryDelay.get().toLong() - - for (i in 0 until config.retryAttempts.get()) { - if (isPaused.get() || config.globalState != true) break - - // Bridge Handshake - if (messaging.conversationManager == null) { - runCatching { context.messagingBridge.triggerSessionStart() } - var waitTime = 0 - while (messaging.conversationManager == null && waitTime < 2000) { delay(100); waitTime += 100 } - } - - success = performOpen(messaging, item) - if (success) { - sessionProcessed.incrementAndGet() - totalProcessed.incrementAndGet() - synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 100) snapTimestamps.removeFirst() } - val duration = System.currentTimeMillis() - startTime - averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong()) - delay(5) - break - } - if (i < config.retryAttempts.get() - 1) { - currentStatusText = "Retrying..."; updateStatusNotification(); delay(currentRetryDelay); currentRetryDelay *= 2 - } - } - - if (!success && !isPaused.get()) { - currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification() - synchronized(openedSnaps) { openedSnaps.remove(item.messageId) } - synchronized(deadLetterQueue) { if (deadLetterQueue.size < 100) deadLetterQueue.add(item) else { deadLetterQueue.removeAt(0); deadLetterQueue.add(item) } } - } - - if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { - currentStatusText = "Monitoring..."; updateStatusNotification() - delay(50) - } - } - } - } - - // Global Detector - context.event.subscribe(BuildMessageEvent::class, priority = 103) { event -> - // GLOBAL SILENCE GUARD - if (config.globalState != true) return@subscribe - - val message = event.message - if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == context.database.myUserId) return@subscribe - - val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe - val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe - val serverMsgId = message.orderKey - val contentType = message.messageContent?.contentType - if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe - - if (config.globalState != true) return@subscribe - - // Whitelist Resilience: Robust rule check - val ruleState = context.config.rules.getRuleState(ruleType) - val isWhitelisted = getState(conversationId) - val canProcess = if (ruleState == me.eternal.purrfectsnap.common.data.RuleState.BLACKLIST) !isWhitelisted else isWhitelisted - - if (!canProcess) return@subscribe - - acquireWakeLock() - synchronized(openedSnaps) { - if (openedSnaps.contains(clientMessageId)) return@subscribe - openedSnaps.add(clientMessageId) - if (openedSnaps.size > 5000) openedSnaps.clear() - } - - val senderId = message.senderId?.toString() ?: "unknown" - val item = SnapQueueItem(conversationId, clientMessageId, serverMsgId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType)) - - synchronized(queuedSnaps) { - if (queuedSnaps.size >= config.queueSize.get()) queuedSnaps.removeFirstOrNull() - queuedSnaps.add(item) - } - - if (context.config.messaging.preFetchSnaps.get()) { - runCatching { messaging.conversationManager?.fetchMessage(conversationId, clientMessageId, {}, {}) } - } - - if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis()) - updateStatusNotification() - triggerLazySave() - } - } - - private suspend fun performOpen(messaging: Messaging, item: SnapQueueItem): Boolean = withContext(Dispatchers.IO) { - val manager = messaging.conversationManager ?: return@withContext false - suspendCancellableCoroutine { cont -> - runCatching { - manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> - if (result == null || result == "DUPLICATEREQUEST") { - cont.resume(true) - } else if (item.serverMessageId != null) { - manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> - cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") - } - } else { - cont.resume(false) - } - } - }.onFailure { cont.resume(false) } - } - } + private fun logInfo(msg: String) = this@AutoOpenSnaps.context.log.info("[AutoOpenEngine] $msg") + private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg", e) else this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg") private fun getSnapsPerSecond(): Double { val now = System.currentTimeMillis(); val window = 5000L synchronized(snapTimestamps) { - snapTimestamps.removeIf { now - it > window }; return (snapTimestamps.size.toDouble() / (window / 1000.0)) + snapTimestamps.removeIf { now - it > window } + // Smoother calculation for high-frequency bursts + return if (snapTimestamps.isEmpty()) 0.0 else (snapTimestamps.size.toDouble() / (window / 1000.0)) + } + } + + private fun formatDuration(m: Long): String { + val s = (m / 1000) % 60; val min = (m / 60000) % 60; val h = m / 3600000 + return when { h > 0 -> "${h}h ${min}m"; min > 0 -> "${min}m ${s}s"; else -> "${s}s" } + } + + override fun init() { + 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 { + hook("appStateChanged", HookStage.BEFORE) { param -> + val state = param.arg(0).toString() + if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null) + } + } + findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply { + hook("onAppForegrounded", HookStage.BEFORE) { param -> param.setResult(null) } + hook("onAppBackgrounded", HookStage.BEFORE) { param -> param.setResult(null) } + } + } + } + + setupReceivers() + startEngineWorker() + setupDetector() + } + + private fun startEngineWorker() { + engineJob = this@AutoOpenSnaps.context.coroutineScope.launch(engineDispatcher) { + while (engineActive.get()) { + val item = try { snapChannel.receive() } catch (e: Exception) { break } + + while (isPaused.get() && engineActive.get()) { + currentStatusText = "Paused"; updateStatusNotification(); delay(500) + } + if (!engineActive.get()) break + + updateStatusNotification() + if (!validateEnvironmentalConstraints()) { + synchronized(queuedSnaps) { queuedSnaps.remove(item) } + 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) + } + lastConversationId = item.conversationId + + processSnapItem(item) + lastSnapProcessedAt.set(System.currentTimeMillis()) + + // HIGH SPEED: 10ms floor for 20+ snaps/s + val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue).get().toLong() + if (isSafe) { + delay(Random.nextLong(baseDelay, baseDelay + 200)) + } else { + delay(baseDelay.coerceAtMost(10)) + } + + if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { + currentStatusText = "Monitoring..." + updateStatusNotification() + } + } + } + } + + private suspend fun processSnapItem(item: SnapQueueItem) { + currentStatusText = "Active"; updateStatusNotification() + var success = false + val startTime = System.currentTimeMillis() + for (i in 0 until (autoOpenConfig.retryAttempts as PropertyValue).get()) { + if (isPaused.get() || !engineActive.get() || autoOpenConfig.globalState == false) break + + if (messaging.conversationManager == null) { + runCatching { this@AutoOpenSnaps.context.messagingBridge.triggerSessionStart() } + delay(1000) + } + + 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 + averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong()) + triggerLazySave(); break + } + delay((autoOpenConfig.retryDelay as PropertyValue).get().toLong()) + } + if (!success && !isPaused.get() && engineActive.get()) { + logError("Engine failed to open Snap: ${item.messageId}") + synchronized(queuedSnaps) { queuedSnaps.remove(item) } + currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification() + openedSnapsIds.remove(item.messageId) + } + } + + private suspend fun performOpen(item: SnapQueueItem): Boolean { + val manager = messaging.conversationManager ?: return false + return suspendCancellableCoroutine { cont -> + runCatching { + manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> + if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) } + else if (item.serverMessageId != 0L) { + manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> + cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") + } + } else { cont.resume(false) } + } + }.onFailure { logError("Bridge Error", it); cont.resume(false) } + } + } + + private suspend fun validateEnvironmentalConstraints(): Boolean { + while (engineActive.get()) { + if (autoOpenConfig.globalState == false || isPaused.get()) return false + val isWifi = isWifiConnected() + val isIdle = isDeviceIdle() + val onlyIdle = (autoOpenConfig.onlyWhenIdle as PropertyValue).get() + val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false + + val wifiStop = (autoOpenConfig.onlyOnWifi as PropertyValue).get() && !isWifi + val idleStop = onlyIdle && !isIdle && !inSleepWindow + + when { + wifiStop -> { currentStatusText = "Waiting for WiFi..."; delay(5000) } + idleStop -> { currentStatusText = "Waiting for Idle..."; delay(5000) } + else -> { + val thermalActive = (autoOpenConfig.thermalProtection as PropertyValue).get() && isThermalThrottled + currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed" + return true + } + } + updateStatusNotification() + } + return false + } + + private fun setupDetector() { + this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event -> + if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe + val message = event.message + if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe + val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe + val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe + val serverMessageId = message.orderKey ?: 0L + + val contentType = message.messageContent?.contentType + if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe + if (!canUseRule(conversationId)) return@subscribe + if (openedSnapsIds.contains(clientMessageId)) return@subscribe + openedSnapsIds.add(clientMessageId) + + val senderId = message.senderId?.toString() ?: "unknown" + val item = SnapQueueItem(conversationId, clientMessageId, serverMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType)) + + synchronized(queuedSnaps) { queuedSnaps.add(item) } + snapChannel.trySend(item) + + acquireWakeLock(); updateStatusNotification(); triggerLazySave() + } + } + + private fun triggerLazySave() { + needsSaving.set(true) + if (isSaving.compareAndSet(false, true)) { + this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.IO) { + while (needsSaving.get() && engineActive.get()) { + needsSaving.set(false); saveQueueToDisk(); delay(LAZY_SAVE_INTERVAL_MS) + } + isSaving.set(false) + } + } + } + + private fun saveQueueToDisk() { + prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) } + } + + private fun restorePersistence() { + val savedStartTime = prefs.getLong(PREF_SESSION_START, 0) + if (System.currentTimeMillis() - savedStartTime > 21600000) return + totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime) + } + + 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 + } + + private fun isDeviceIdle(): Boolean = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode + private fun isInsideSleepWindow(): Boolean { + val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + return hour >= 23 || hour <= 6 + } + + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + wakeLock = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { acquire(8 * 60 * 60 * 1000L) } + } + + private fun releaseWakeLock() { if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.createNotificationChannel(NotificationChannel("auto_open_status", "Auto-Open Status", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) }) } } private fun updateStatusNotification(force: Boolean = false) { - val currentTime = System.currentTimeMillis(); val lastUpdate = lastNotificationUpdate.get() - val remaining = synchronized(queuedSnaps) { queuedSnaps.size } - if (!isScreenOn.get() && !force) return - if (!force && (currentTime - lastUpdate) < notificationUpdateDelay) { + val now = System.currentTimeMillis() + if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) { if (pendingNotificationUpdate.compareAndSet(false, true)) { - context.coroutineScope.launch { delay(notificationUpdateDelay - (currentTime - lastUpdate)); pendingNotificationUpdate.set(false); updateStatusNotificationInternal() } + this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() } } return } - lastNotificationUpdate.set(currentTime); updateStatusNotificationInternal() + updateStatusNotificationInternal() } - private var lastNotificationStateHash: Int = 0 - private fun updateStatusNotificationInternal() { + if (!engineActive.get()) return val processed = sessionProcessed.get() val total = totalProcessed.get() val remaining = synchronized(queuedSnaps) { queuedSnaps.size } - - val currentStateHash = Objects.hash(processed, total, remaining, currentStatusText, isPaused.get()) - if (currentStateHash == lastNotificationStateHash && remaining == 0) return - lastNotificationStateHash = currentStateHash - - if (total <= 0 && remaining <= 0 && processed <= 0) return - val isWorking = remaining > 0 - val isCompact = config.compactNotification.get() == true + val speed = if (isWorking) getSnapsPerSecond() else 0.0 + + lastNotificationUpdate = System.currentTimeMillis(); pendingNotificationUpdate.set(false) + val sessionTotal = processed + remaining - val speed = getSnapsPerSecond() val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0 - val eta = if (isWorking && !isCurrentlyWaiting && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..." - - val builder = Notification.Builder(context.androidContext, "auto_open_snaps") - .setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play) - .setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY).setGroupSummary(false) + val eta = if (isWorking && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..." + 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 + else -> android.R.drawable.ic_media_play + } + builder.setSmallIcon(iconRes) builder.setContentTitle("Auto-Open: $currentStatusText") + val isCompact = (autoOpenConfig.compactNotification as PropertyValue).get() if (isWorking) { - builder.setContentText("Opened: $processed │ Queue: $remaining") - builder.setSubText("$progressPercent% • Ends in: ${eta ?: "..."}") + builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)") + builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta") builder.setProgress(sessionTotal, processed, false) } else { builder.setContentText("$processed Opened Today │ $total Total") @@ -444,7 +368,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } builder.addAction(Notification.Action.Builder(null, if (isPaused.get()) "Resume" else "Pause", createPendingIntent(ACTION_PAUSE_RESUME)).build()) - builder.addAction(Notification.Action.Builder(null, "Clear Queue", createPendingIntent(ACTION_CLEAR_QUEUE)).build()) + builder.addAction(Notification.Action.Builder(null, "Clear", createPendingIntent(ACTION_CLEAR_QUEUE)).build()) + builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP_ENGINE)).build()) if (!isCompact) { val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) } @@ -452,16 +377,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val detailText = buildString { append("QUEUE STATISTICS\n") append("├─ Opened: $processed snaps\n") - append("├─ Queue: $remaining snaps\n") - append("├─ Total Opened: $total snaps\n") - val speedNotion = if (remaining > 0) currentSpeedText else "Idle" - val speedValue = if (remaining > 0) "${String.format("%.1f", speed)}/s" else "0.0/s" - append("└─ Speed: $speedNotion ($speedValue)\n\n") + append("├─ Queue: $remaining snaps • Ends in: $eta\n") + if ((autoOpenConfig.showLifetimeStats as PropertyValue).get()) { + append("├─ Total Opened: $total snaps\n") + } + val speedNotion = if (isWorking) currentSpeedText else "Idle" + val speedValue = "${String.format(Locale.US, "%.1f", speed)}/s" + append("└─ Speed: $speedNotion ($speedValue)\n") - - if (config.showQueuePreview.get()) { - append("\n\nQUEUE PREVIEW\n") - if (isWorking) { + if ((autoOpenConfig.showQueuePreview as PropertyValue).get()) { + append("\nQUEUE PREVIEW\n") + if (isWorking && remaining > 0) { recentSnaps.reversed().forEach { item -> append("• ${item.senderName} │ ${item.conversationType} (${item.contentType})\n") } @@ -473,114 +399,58 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A bigTextStyle.bigText(detailText) builder.setStyle(bigTextStyle) } - + notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) } - private fun formatDuration(m: Long): String { - val s = (m / 1000) % 60; val min = (m / 60000) % 60; val h = m / 3600000 - return when { h > 0 -> "${h}h ${min}m"; min > 0 -> "${min}m ${s}s"; else -> "${s}s" } + private fun createPendingIntent(action: String): PendingIntent { + val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName) + return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } - private fun shutdownFeature() { - cancelStatusNotification(); releaseWakeLock(); hasBeenActive.set(false); triggerLazySave() - } - - private fun startWakeLockCooldown() { - wakeLockCooldownJob?.cancel() - wakeLockCooldownJob = context.coroutineScope.launch { - delay(30000) - releaseWakeLock() - } - } - - private fun triggerLazySave() { - needsSaving.set(true) - if (isSaving.compareAndSet(false, true)) { - context.coroutineScope.launch(Dispatchers.IO) { - while (needsSaving.get()) { needsSaving.set(false); saveToDiskInternal(); delay(300000) } - isSaving.set(false) + private fun setupReceivers() { + val actionReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_PAUSE_RESUME -> { isPaused.set(!isPaused.get()); updateStatusNotification(force = true) } + ACTION_CLEAR_QUEUE -> { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) } + ACTION_STOP_ENGINE -> shutdownFeature() + Intent.ACTION_BATTERY_CHANGED -> { + val temp = intent.getIntExtra("temperature", 0) / 10f + if (temp >= 40f && !isThermalThrottled) { isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis() } + else if (isThermalThrottled && temp <= 36f && (System.currentTimeMillis() - lastThermalThrottleAt > 600000)) { isThermalThrottled = false } + } + } } } + val filter = IntentFilter().apply { addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(ACTION_STOP_ENGINE); addAction(Intent.ACTION_BATTERY_CHANGED) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter) } - private fun saveToDiskInternal() { - prefs.edit { - putInt(PREF_TOTAL_OPENED, totalProcessed.get()) - putLong(PREF_SESSION_START, sessionStartTime.get()) - synchronized(queuedSnaps) { putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps)) } + private fun recordSpeedTimestamp() { synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 250) snapTimestamps.removeFirst() } } + + private fun shutdownFeature() { + engineActive.set(false) + snapChannel.close() + engineJob?.cancel() + releaseWakeLock() + cancelStatusNotification() + } + + private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID) + + fun getInterface(): AutoOpenInterface { + return object : AutoOpenInterface.Stub() { + override fun getProcessedCount(): Int = totalProcessed.get() + override fun getQueueItems(): List = synchronized(queuedSnaps) { queuedSnaps.map { gson.toJson(it) } } + override fun reset() { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) } } } - private fun restorePersistence() { - val savedStartTime = prefs.getLong(PREF_SESSION_START, 0) - val now = System.currentTimeMillis() - if (now - savedStartTime > 3600000) { - prefs.edit().remove(PREF_SAVED_QUEUE).remove(PREF_TOTAL_OPENED).apply(); return - } - totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)) - sessionStartTime.set(savedStartTime) - val savedQueueJson = prefs.getString(PREF_SAVED_QUEUE, null) - if (!savedQueueJson.isNullOrBlank()) { - try { - val restored: List = gson.fromJson(savedQueueJson, object : TypeToken>() {}.type) - synchronized(queuedSnaps) { queuedSnaps.addAll(restored.filter { (now - it.timestamp) < 3600000 }) } - } catch (e: Exception) { prefs.edit().remove(PREF_SAVED_QUEUE).apply() } - } - } - - private fun isInsideSleepWindow(): Boolean { - try { - val window = config.sleepWindow.get().split("-"); if (window.size != 2) return false - val start = window[0].split(":"); val end = window[1].split(":") - val now = Calendar.getInstance().apply { set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) } - val s = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, start[0].toInt()); set(Calendar.MINUTE, start[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) } - val e = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, end[0].toInt()); set(Calendar.MINUTE, end[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) } - return if (e.before(s)) now.after(s) || now.before(e) else now.after(s) && now.before(e) - } catch (e: Exception) { return false } - } - - private fun isWifiConnected(): Boolean { - val cm = context.androidContext.getSystemService(ConnectivityManager::class.java) ?: return false - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - cm.allNetworks.any { cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true } - } else { - @Suppress("DEPRECATION") cm.activeNetworkInfo?.type == ConnectivityManager.TYPE_WIFI - } - } - - private fun isDeviceIdle(): Boolean = (context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode - - private fun acquireWakeLock() { - wakeLockCooldownJob?.cancel() - if (wakeLock == null) { - val pm = context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager - wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { setReferenceCounted(false) } - wakeLock?.acquire(8 * 60 * 60 * 1000L) - } - } - - private fun releaseWakeLock() { - if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null - } - - private fun createPendingIntent(a: String): PendingIntent { - val i = Intent(a).apply { setPackage(context.androidContext.packageName) } - return PendingIntent.getBroadcast(context.androidContext, a.hashCode(), i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - - private fun createNotificationChannels() { - val c = NotificationChannel("auto_open_snaps", "Auto Open Snaps", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) } - notificationManager.createNotificationChannel(c) - } - - private fun getSenderDisplayName(id: String): String = metadataCache.getOrPut(id) { context.database.getFriendInfo(id)?.let { it.displayName ?: it.mutableUsername } ?: "Unknown" } - - private fun getConversationType(cid: String, sid: String): String = metadataCache.getOrPut("$cid:$sid") { if (context.database.getDMOtherParticipant(cid) != null) "Friend DM" else context.database.getFeedEntryByConversationId(cid)?.feedDisplayName ?: "Group Chat" } - - private fun getSnapContentType(type: ContentType?): String = when (type) { - ContentType.SNAP -> "Photo/Video" - ContentType.EXTERNAL_MEDIA -> "Media" - else -> context.translation["auto_open_snaps.content_type_snap"] ?: "Snap" - } + private fun getSenderDisplayName(userId: String): String = this@AutoOpenSnaps.context.database.getFriendInfo(userId)?.displayName ?: "Unknown" + private fun getConversationType(convId: String, senderId: String): String = if (this@AutoOpenSnaps.context.database.getDMOtherParticipant(convId) != null) "Friend DM" else this@AutoOpenSnaps.context.database.getFeedEntryByConversationId(convId)?.feedDisplayName ?: "Group Chat" + private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" } } + +data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String, val timestamp: Long = System.currentTimeMillis()) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt index aa5fbb98..048e9ad1 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt @@ -8,11 +8,9 @@ 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.mapper.impl.PlusSubscriptionMapper -import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter class SnapchatPlus: Feature("SnapchatPlus") { + private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L) private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L) override fun init() { @@ -42,24 +40,7 @@ class SnapchatPlus: Feature("SnapchatPlus") { //subscription status set(statusField.getAsString()!!, 2) - val fallbackOriginalSubscriptionTime = System.currentTimeMillis() - 7776000000L - val customPurchaseDate = context.config.global.snapchatPlusPurchaseDate.get().trim() - val customPurchaseDateMillis = if (customPurchaseDate.isNotEmpty()) { - runCatching { - LocalDate - .parse(customPurchaseDate, DateTimeFormatter.ISO_LOCAL_DATE) - .atStartOfDay(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli() - }.getOrNull() - } else { - null - } - - set( - originalSubscriptionTimeMillisField.getAsString()!!, - customPurchaseDateMillis ?: fallbackOriginalSubscriptionTime - ) + set(originalSubscriptionTimeMillisField.getAsString()!!, originalSubscriptionTime) set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis) } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Notifications.kt index b91d77b6..9e6c88f5 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Notifications.kt @@ -118,7 +118,7 @@ class Notifications : Feature("Notifications") { val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) { putExtra("conversation_id", conversationId) putExtra("notification_id", notificationData.id) - putExtra("client_message_id", message.messageDescriptor!!.messageId!!) + putExtra("client_message_id", message.messageDescriptor!!.messageId!!.toLong()) } val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( @@ -160,7 +160,9 @@ class Notifications : Feature("Notifications") { context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event -> val intent = event.intent ?: return@subscribe val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe - val clientMessageId = intent.getLongExtra("client_message_id", -1) + val clientMessageId = intent.getLongExtra("client_message_id", -1L).takeIf { it != -1L } + ?: intent.getStringExtra("client_message_id")?.toLongOrNull() + ?: intent.getIntExtra("client_message_id", -1).toLong() val notificationId = intent.getIntExtra("notification_id", -1) val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder -> @@ -209,10 +211,15 @@ class Notifications : Feature("Notifications") { }) } ACTION_DOWNLOAD -> { - runCatching { - context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false) - }.onFailure { - context.longToast(it) + context.shortToast(context.translation.getCategory("download_processor")["download_started_toast"] ?: "Downloading...") + context.coroutineScope.launch(coroutineDispatcher) { + runCatching { + if (clientMessageId <= 0) throw Exception("Message not found or expired in database.") + context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false) + }.onFailure { + val msg = if (it.message?.contains("not found", true) == true) "Message expired or already viewed." else it.message + context.longToast("Download failed: $msg") + } } } ACTION_MARK_AS_READ -> { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/ConversationToolbox.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/ConversationToolbox.kt index 1053823b..d67f552e 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/ConversationToolbox.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/ConversationToolbox.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.times +import me.eternal.purrfectsnap.common.scripting.JSModule import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/scripting/CoreScriptRuntime.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/scripting/CoreScriptRuntime.kt index 847ba6e4..db6a3187 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/scripting/CoreScriptRuntime.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/scripting/CoreScriptRuntime.kt @@ -1,5 +1,6 @@ package me.eternal.purrfectsnap.core.scripting +import me.eternal.purrfectsnap.common.scripting.JSModule import me.eternal.purrfectsnap.bridge.scripting.AutoReloadListener import me.eternal.purrfectsnap.common.logger.AbstractLogger import me.eternal.purrfectsnap.common.scripting.ScriptRuntime @@ -7,6 +8,11 @@ import me.eternal.purrfectsnap.common.scripting.bindings.BindingSide import me.eternal.purrfectsnap.core.ModContext import me.eternal.purrfectsnap.core.scripting.impl.* +/** + * Core-side implementation of the [ScriptRuntime]. + * Manages script lifecycle synchronized with the JNI bridge connection state + * to prevent race conditions during early-init hooks. + */ class CoreScriptRuntime( private val modContext: ModContext, logger: AbstractLogger, @@ -15,9 +21,18 @@ class CoreScriptRuntime( androidContext = modContext.androidContext, logger = logger ) { - // we assume that the bridge is reloaded the next time we connect to it + // Indicates if the bridge has been reloaded at least once in this session private var isBridgeReloaded = false + /** + * Bridge connection status. Use [isBridgeConnected] to guard JNI-dependent operations. + */ + @Volatile + private var isBridgeConnected = false + + /** + * Initializes the scripting environment and establishes bridge-aware lifecycle observers. + */ fun init() { buildModuleObject = { module -> putConst("currentSide", this, BindingSide.CORE.key) @@ -32,11 +47,14 @@ class CoreScriptRuntime( modContext.bridgeClient.addOnConnectedCallback(initNow = true) { modContext.bridgeClient.getScriptingInterface()?.let { scriptingInterface -> + logger.info("JNI Bridge established. Initializing scripts...") scripting = scriptingInterface + isBridgeConnected = true if (!isBridgeReloaded) { scriptingInterface.enabledScripts.forEach { path -> runCatching { + logger.verbose("Loading script: $path") load(path, scriptingInterface.getScriptContent(path)) }.onFailure { logger.error("Failed to load script $path", it) @@ -46,6 +64,7 @@ class CoreScriptRuntime( scriptingInterface.registerAutoReloadListener(object : AutoReloadListener.Stub() { override fun restartApp() { + logger.info("Script change detected. Soft-restarting app...") modContext.softRestartApp() } }) @@ -57,7 +76,18 @@ class CoreScriptRuntime( if (!isBridgeReloaded) { isBridgeReloaded = true } + } ?: run { + isBridgeConnected = false + logger.error("JNI Bridge callback triggered but interface is null.") } } } + + /** + * Safely iterates over loaded modules only when the JNI bridge is confirmed connected. + */ + override fun eachModule(f: JSModule.() -> Unit) { + if (!isBridgeConnected) return + super.eachModule(f) + } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt index 1e045fd0..f5505a30 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -43,6 +43,7 @@ import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.FriendLinkType import me.eternal.purrfectsnap.common.database.impl.ConversationMessage import me.eternal.purrfectsnap.common.database.impl.FriendInfo +import me.eternal.purrfectsnap.common.scripting.JSModule import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt index bb74b4e0..f9052bf0 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt @@ -22,29 +22,6 @@ class SettingsMenu : AbstractMenu() { view.setOnClickListener { context.bridgeClient.openOverlay(OverlayType.SETTINGS) } - view.setOnLongClickListener { - val holdKillConfig = context.config.userInterface.chatButtonHoldKill - if (!holdKillConfig.enabled.get()) { - return@setOnLongClickListener false - } - - val targetApps = holdKillConfig.targetApps.get() - val shouldKillModule = targetApps.contains("kill_purrfectsnap") - val shouldKillSnapchat = targetApps.contains("kill_snapchat") - - if (shouldKillModule) { - runCatching { - context.bridgeClient.terminateModuleProcess() - }.onFailure { - context.log.error("Failed to terminate PurrfectSnap module process", it, "SettingsMenu") - } - } - if (shouldKillSnapchat) { - context.forceCloseApp() - } - - shouldKillModule || shouldKillSnapchat - } } } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/opera/ParamMap.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/opera/ParamMap.kt index 630e3dda..81288de4 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/opera/ParamMap.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/opera/ParamMap.kt @@ -30,6 +30,38 @@ class ParamMap(obj: Any?) : AbstractWrapper(obj) { return concurrentHashMap.keys.any { k: Any -> k.toString() == key } } + fun getStoryIdentity(): String? { + return this["STORY_ID"]?.toString() + ?.takeIf { it.isNotBlank() && it != "null" } + ?: this["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() + ?.takeIf { it.isNotBlank() && it != "null" } + ?: this["STORY_SNAP_ID"]?.toString() + ?.substringBefore("_") + ?.takeIf { it.isNotBlank() && it != "null" } + ?: this["PLAYLIST_V2_GROUP"]?.toString() + ?.substringAfter("storyUserId=", "") + ?.substringBefore(",") + ?.takeIf { it.isNotBlank() && it != "null" } + ?: this["PLAYABLE_STORY_SNAP_RECORD"]?.toString() + ?.substringAfter("storyUserId=", "") + ?.substringBefore(",") + ?.takeIf { it.isNotBlank() && it != "null" } + } + + fun getStorySnapIndex(): Int? { + return (this["STORY_SNAP_INDEX"] as? Int) + ?: (this["snap_index_in_story"]?.toString()?.toIntOrNull()) + ?: (this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull()) + ?: (this["REPLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("snapIndex=", "")?.substringBefore(",")?.toIntOrNull()) + } + + fun getStorySnapTotal(): Int { + return (this["STORY_SNAP_TOTAL"] as? Int) + ?: (this["snap_story_length"]?.toString()?.toIntOrNull()) + ?: (this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull()) + ?: 0 + } + override fun toString(): String { return concurrentHashMap.toString() } diff --git a/native/rust/Cargo.lock b/native/rust/Cargo.lock index d2e497fb..1b2b1eb2 100644 --- a/native/rust/Cargo.lock +++ b/native/rust/Cargo.lock @@ -107,9 +107,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzip2" @@ -563,9 +563,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -821,18 +821,28 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -948,22 +958,22 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "typenum" diff --git a/native/rust/src/config.rs b/native/rust/src/config.rs index ab781640..50e30ab1 100644 --- a/native/rust/src/config.rs +++ b/native/rust/src/config.rs @@ -8,12 +8,17 @@ pub fn native_config() -> NativeConfig { NATIVE_CONFIG.lock().unwrap().as_ref().expect("NativeConfig not loaded").clone() } +/// Native configuration structure mirrored from 'NativeConfig.kt'. +/// +/// CRITICAL: Fields must maintain 1:1 parity with the Kotlin implementation. +/// Mismatches in field names, types, or order will result in a JNI SIGABRT. #[derive(Debug, Clone)] pub(crate) struct NativeConfig { pub disable_bitmoji: bool, pub disable_metrics: bool, pub valdi_hooks: bool, pub custom_emoji_font_path: Option, + pub debug_font_redirect: bool, } impl NativeConfig { @@ -41,6 +46,7 @@ impl NativeConfig { disable_metrics: get_boolean!("disableMetrics"), valdi_hooks: get_boolean!("valdiHooks"), custom_emoji_font_path: get_string!("customEmojiFontPath"), + debug_font_redirect: get_boolean!("debugFontRedirect"), }) } } diff --git a/native/rust/src/modules/custom_font_hook.rs b/native/rust/src/modules/custom_font_hook.rs index f76d3f9a..7aeded9d 100644 --- a/native/rust/src/modules/custom_font_hook.rs +++ b/native/rust/src/modules/custom_font_hook.rs @@ -1,7 +1,5 @@ use std::{cell::Cell, ffi::{CStr, CString}}; - use nix::libc::{self, c_uint}; - use crate::{config, def_hook, dobby_hook_sym}; thread_local! { @@ -23,6 +21,8 @@ fn should_redirect_font(pathname: &str) -> bool { file_name.contains("emoji") || file_name == "noto_color_emoji.ttf" || file_name == "samsungcoloremoji.ttf" + || file_name == "coloremojifont.ttf" + || file_name == "coloros_color_emoji.ttf" ) } @@ -33,7 +33,7 @@ fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option { Ok(c_font_path) => { let fd = FONT_REDIRECT_IN_PROGRESS.with(|guard| { let was_active = guard.replace(true); - let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode) }; + let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const libc::c_char, flags, mode) }; guard.set(was_active); fd }); @@ -41,6 +41,9 @@ fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option { debug!("redirected emoji font open to {}", font_path); Some(fd) } else { + if config::native_config().debug_font_redirect { + panic!("Failed to open custom emoji font: {}", font_path); + } debug!("failed to open custom emoji font path (fd={}): {}", fd, font_path); None } @@ -61,7 +64,7 @@ def_hook!( } if !path.is_null() { - if let Ok(pathname) = CStr::from_ptr(path).to_str() { + if let Ok(pathname) = unsafe { CStr::from_ptr(path as *const libc::c_char) }.to_str() { if should_redirect_font(pathname) { if let Some(fd) = open_custom_font_fd(flags, mode) { return fd; @@ -74,10 +77,33 @@ def_hook!( } ); +def_hook!( + openat_hook, + i32, + |dirfd: i32, path: *const u8, flags: i32, mode: c_uint| { + if FONT_REDIRECT_IN_PROGRESS.with(|guard| guard.get()) { + return openat_hook_original.unwrap()(dirfd, path, flags, mode); + } + + if !path.is_null() { + if let Ok(pathname) = unsafe { CStr::from_ptr(path as *const libc::c_char) }.to_str() { + if should_redirect_font(pathname) { + if let Some(fd) = open_custom_font_fd(flags, mode) { + return fd; + } + } + } + } + + openat_hook_original.unwrap()(dirfd, path, flags, mode) + } +); + pub fn init() { if config::native_config().custom_emoji_font_path.is_none() { return; } dobby_hook_sym!("libc.so", "open", open_hook); + dobby_hook_sym!("libc.so", "openat", openat_hook); } diff --git a/native/rust/src/modules/util/valdi_utils.rs b/native/rust/src/modules/util/valdi_utils.rs index 6dc4cd31..30733b6e 100644 --- a/native/rust/src/modules/util/valdi_utils.rs +++ b/native/rust/src/modules/util/valdi_utils.rs @@ -42,6 +42,9 @@ pub struct ValdiModule { impl ValdiModule { pub fn parse(buffer: Vec) -> Result { + if buffer.len() < 8 { + return Err(Error::new(std::io::ErrorKind::InvalidData, "Buffer too small")); + } let mut offset = 0; let magic = u32::from_be_bytes([buffer[offset], buffer[offset + 1], buffer[offset + 2], buffer[offset + 3]]); @@ -62,14 +65,13 @@ impl ValdiModule { } fn read_u32(buffer: &Vec, offset: &mut usize) -> Result<(u32, bool), Error> { - let b1 = buffer[*offset] as u32; - let b2 = buffer[*offset + 1] as u32; - let b3 = buffer[*offset + 2] as u32; - let b4 = (buffer[*offset + 3] & 0x7f) as u32; - let has_padding = (buffer[*offset + 3] & 0x80) != 0; + let bytes = [buffer[*offset], buffer[*offset + 1], buffer[*offset + 2], buffer[*offset + 3]]; + let value = u32::from_be_bytes(bytes); + let has_padding = (value & 0x80000000) != 0; + let tag_size = value & 0x7FFFFFFF; *offset += 4; - Ok((b1 | (b2 << 8) | (b3 << 16) | (b4 << 24), has_padding)) + Ok((tag_size, has_padding)) } let (tag_size, has_padding) = read_u32(&buffer, &mut offset)?; @@ -98,10 +100,8 @@ impl ValdiModule { let mut tag_buffer = Vec::new(); fn write_u32(buffer: &mut Vec, value: u32, has_padding: bool) { - buffer.push(value as u8); - buffer.push(((value >> 8) & 0xff) as u8); - buffer.push(((value >> 16) & 0xff) as u8); - buffer.push(((value >> 24) & 0x7f) as u8 | if has_padding { 0x80 } else { 0x00 }); + let encoded_value = (value & 0x7FFFFFFF) | if has_padding { 0x80000000 } else { 0 }; + buffer.extend_from_slice(&encoded_value.to_be_bytes()); } fn write_tag(buffer: &mut Vec, tag: ModuleTag) { @@ -125,7 +125,7 @@ impl ValdiModule { let mut buffer = Vec::new(); buffer.extend_from_slice(&[0x33, 0xc6, 0, 1]); - buffer.extend_from_slice(&(tag_buffer.len() as u32).to_le_bytes()); + buffer.extend_from_slice(&(tag_buffer.len() as u32).to_be_bytes()); buffer.extend(tag_buffer); buffer diff --git a/native/rust/src/modules/valdi_hook.rs b/native/rust/src/modules/valdi_hook.rs index d910ecdd..298fabac 100644 --- a/native/rust/src/modules/valdi_hook.rs +++ b/native/rust/src/modules/valdi_hook.rs @@ -16,7 +16,10 @@ def_hook!( if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) { return buffer.len() as i32; } - aasset_get_length_original.unwrap()(arg0) + if let Some(original) = aasset_get_length_original { + return original(arg0); + } + 0 } ); @@ -27,7 +30,10 @@ def_hook!( if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) { return buffer.as_ptr() as *const c_void; } - aasset_get_buffer_original.unwrap()(arg0) + if let Some(original) = aasset_get_buffer_original { + return original(arg0); + } + std::ptr::null() } ); @@ -35,53 +41,89 @@ def_hook!( aasset_manager_open, *mut c_void, |arg0: *mut c_void, arg1: *const u8, arg2: i32| { - let handle = aasset_manager_open_original.unwrap()(arg0, arg1, arg2); + let original_fn = match aasset_manager_open_original { + Some(f) => f, + None => return std::ptr::null_mut(), + }; - let path = std::ffi::CStr::from_ptr(arg1).to_str().unwrap_or_default(); - if !handle.is_null() && path.starts_with("bridge_observables") { - let asset_buffer = aasset_get_buffer_original.unwrap()(handle); - let asset_length = aasset_get_length_original.unwrap()(handle); - debug!("asset buffer: {:p}, length: {}", asset_buffer, asset_length); + let handle = original_fn(arg0, arg1, arg2); + if handle.is_null() { + return handle; + } - let loader_data = LOADER_DATA.lock().unwrap().clone().expect("No loader data"); + let path_cstr = unsafe { std::ffi::CStr::from_ptr(arg1 as *const std::os::raw::c_char) }; + let path = path_cstr.to_str().unwrap_or_default(); + + // Only target compressed Valdi bridge observables + if path.ends_with(".zst") && path.contains("bridge_observables") { + let get_buffer_fn = match aasset_get_buffer_original { + Some(f) => f, + None => return handle, + }; + let get_length_fn = match aasset_get_length_original { + Some(f) => f, + None => return handle, + }; - let archive_buffer: Vec = std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec(); - let decompressed = zstd::stream::decode_all(&archive_buffer[..]).expect("Failed to decompress valdi archive"); - let mut valdi_module = ValdiModule::parse(decompressed).expect("Failed to parse valdi module"); - - let mut tags = valdi_module.get_tags(); - let mut new_tags = Vec::new(); - - for (tag1, _) in tags.iter_mut() { - let name = tag1.to_string().unwrap_or_default(); - if !name.ends_with("src/utils/converter.js") { - continue; - } - - let old_file_name = name.split_once(".").unwrap().0.to_owned() + rand::random::().to_string().as_str(); - tag1.set_buffer((old_file_name.to_owned() + ".js").as_bytes().to_vec()); - let original_module_path = path.split_once(".").unwrap().0.to_owned() + "/" + &old_file_name; - - let hooked_module = format!("{};module.exports = require(\"{}\");", loader_data, original_module_path); - - new_tags.push( - ( - ModuleTag::new(true, name.as_bytes().to_vec()), - ModuleTag::new(true, hooked_module.as_bytes().to_vec()) - ) - ); - - debug!("Valdi loader injected in {}", name); - break; + let asset_buffer = get_buffer_fn(handle); + let asset_length = get_length_fn(handle); + + if asset_buffer.is_null() || asset_length <= 0 { + return handle; } - tags.extend(new_tags); - valdi_module.set_tags(tags); + let loader_data = match LOADER_DATA.lock().unwrap().clone() { + Some(data) => data, + None => { + warn!("Valdi loader data not yet initialized for {}", path); + return handle; + } + }; - let compressed = valdi_module.to_bytes(); - let compressed = zstd::stream::encode_all(&compressed[..], 3).expect("Failed to compress"); + let archive_buffer: Vec = unsafe { + std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec() + }; + + let decompressed = match zstd::stream::decode_all(&archive_buffer[..]) { + Ok(data) => data, + Err(e) => { + error!("Failed to decompress Valdi archive {}: {}", path, e); + return handle; + } + }; - AASSET_MAP.lock().unwrap().insert(handle as usize, compressed); + let valdi_module = match ValdiModule::parse(decompressed) { + Ok(module) => module, + Err(e) => { + error!("Failed to parse Valdi module {}: {}", path, e); + return handle; + } + }; + + let mut tags = valdi_module.get_tags(); + let mut found = false; + + for (tag1, tag2) in tags.iter_mut() { + let name = tag1.to_string().unwrap_or_default(); + if name.ends_with("src/utils/converter.js") { + let mut hooked_content = loader_data.as_bytes().to_vec(); + hooked_content.extend_from_slice(tag2.get_buffer()); + *tag2 = ModuleTag::new(true, hooked_content); + found = true; + debug!("Valdi loader prepended to {}", name); + break; + } + } + + if found { + let compressed = valdi_module.to_bytes(); + match zstd::stream::encode_all(&compressed[..], 3) { + Ok(compressed_data) => { + AASSET_MAP.lock().unwrap().insert(handle as usize, compressed_data); + }, + Err(e) => error!("Failed to re-compress Valdi module: {}", e), + } + } } handle } @@ -89,16 +131,19 @@ def_hook!( def_hook!( aasset_close, - c_void, + (), |handle: *mut c_void| { AASSET_MAP.lock().unwrap().remove(&(handle as usize)); - aasset_close_original.unwrap()(handle) + if let Some(original) = aasset_close_original { + original(handle); + } } ); pub fn set_valdi_loader(mut env: JNIEnv, _: *mut c_void, code: JString) { - let new_code = get_jni_string(&mut env, code).expect("Failed to get loader code"); - LOADER_DATA.lock().unwrap().replace(new_code); + if let Ok(new_code) = get_jni_string(&mut env, code) { + LOADER_DATA.lock().unwrap().replace(new_code); + } } pub fn init() { diff --git a/native/src/main/kotlin/me/eternal/purrfectsnap/nativelib/NativeConfig.kt b/native/src/main/kotlin/me/eternal/purrfectsnap/nativelib/NativeConfig.kt index 6d8eefa7..24b57a08 100644 --- a/native/src/main/kotlin/me/eternal/purrfectsnap/nativelib/NativeConfig.kt +++ b/native/src/main/kotlin/me/eternal/purrfectsnap/nativelib/NativeConfig.kt @@ -1,5 +1,12 @@ package me.eternal.purrfectsnap.nativelib +/** + * Configuration schema for the native layer. + * + * CRITICAL: This class MUST maintain 1:1 field parity with 'native/rust/src/config.rs'. + * Any modification to field names, types, or order without a corresponding change + * in the Rust implementation will cause a JNI SIGABRT (crash on launch). + */ data class NativeConfig( @JvmField val disableBitmoji: Boolean = false, @@ -9,4 +16,6 @@ data class NativeConfig( val valdiHooks: Boolean = false, @JvmField val customEmojiFontPath: String? = null, + @JvmField + val debugFontRedirect: Boolean = false, ) From 2913cfe794757a8d86fe82c569ec933309694f4e Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:12:25 +0000 Subject: [PATCH 18/33] Merge pull request #4 from imCrest/imCrest/add-customizable-purchase-date-feature-di5qqb Add Snapchat Plus purchase date setting and chat-hold kill actions (UI + core) --- .../pages/features/FeaturesRootSection.kt | 14 ++++ .../purrfectsnap/ui/util/AlertDialogs.kt | 67 ++++++++++++++++++- common/src/main/assets/lang/en_US.json | 13 ++++ .../purrfectsnap/common/config/impl/Global.kt | 8 +++ .../common/config/impl/UserInterfaceTweaks.kt | 1 + .../core/features/impl/global/SnapchatPlus.kt | 23 ++++++- .../core/ui/menu/impl/SettingsGearInjector.kt | 26 +++++++ .../core/ui/menu/impl/SettingsMenu.kt | 26 +++++++ 8 files changed, 175 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index 764580e8..e0d9dfa9 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -779,11 +779,14 @@ class FeaturesRootSection : Routes.Route() { DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> { val isMessageListProperty = property.key.name.endsWith("_messages") val isSleepWindowProperty = property.key.name.contains("sleep_window") + val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date" if (isMessageListProperty) { alertDialogs.MessageListPropertyDialog(property) { showDialog = false } } else if (isSleepWindowProperty) { alertDialogs.AutoOpenScheduleDialog(property as PropertyPair) { showDialog = false } + } else if (isSnapchatPlusPurchaseDateProperty) { + alertDialogs.DatePickerPropertyDialog(property) { showDialog = false } } else { alertDialogs.KeyboardInputDialog(property) { showDialog = false } } @@ -801,6 +804,7 @@ class FeaturesRootSection : Routes.Route() { ) } else { val isMessageListProperty = property.key.name.endsWith("_messages") + val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date" if (isMessageListProperty) { val messageCount = try { val messageList: List = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList() @@ -822,6 +826,16 @@ class FeaturesRootSection : Routes.Route() { color = Color.White ) } + } else if (isSnapchatPlusPurchaseDateProperty) { + Button( + onClick = click, + colors = ButtonDefaults.buttonColors( + containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f), + contentColor = Color.White + ) + ) { + Text(translation["button.set"] ?: "Set") + } } else { IconButton(onClick = click) { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt index d386e1fa..b46892e9 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt @@ -75,6 +75,10 @@ import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Overlay import java.io.File +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog @@ -512,6 +516,68 @@ class AlertDialogs( } } + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) { + val context = LocalContext.current + val zoneId = remember { ZoneId.systemDefault() } + val initialSelectedDateMillis = remember(property.value.get()) { + runCatching { + LocalDate + .parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE) + .atStartOfDay(zoneId) + .toInstant() + .toEpochMilli() + }.getOrNull() + } + val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis) + + DefaultDialogCard { + DatePicker( + state = datePickerState, + showModeToggle = true + ) + + Row( + modifier = Modifier + .padding(top = 10.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + ) { + Button( + onClick = { dismiss() }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White.copy(alpha = 0.08f), + contentColor = Color.White + ) + ) { + Text(text = translation["button.cancel"]) + } + Button( + onClick = { + val selectedDate = datePickerState.selectedDateMillis?.let { + Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate() + } + + if (selectedDate == null) { + Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show() + return@Button + } + + property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE)) + dismiss() + }, + colors = ButtonDefaults.buttonColors( + containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f), + contentColor = Color.White + ) + ) { + Text(text = translation["button.ok"]) + } + } + } + } + @Composable fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) { val focusRequester = remember { FocusRequester() } @@ -1615,4 +1681,3 @@ class AlertDialogs( } } } - diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 07bd00cc..7369cf61 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -1240,6 +1240,10 @@ "name": "Settings Menu", "description": "Choose between the new and legacy settings menu layouts" }, + "chat_hold_kill_actions": { + "name": "PurrfectSnap Chat Hold Kill", + "description": "Hold the chat/settings header button to kill selected app(s). Leave all options off to disable." + }, "spoof_snap_score": { "name": "Spoof Snap Score", "description": "Spoof your Snap Score (local only)", @@ -1919,6 +1923,10 @@ "name": "Snapchat Plus", "description": "Enables Snapchat Plus features\nSome Server-sided features may not work" }, + "snapchat_plus_purchase_date": { + "name": "Snapchat Plus Purchase Date", + "description": "Tap Save to choose a date from calendar (leave empty to use default)" + }, "media_upload_quality": { "name": "Media Upload Quality", "description": "Overrides the media upload quality", @@ -2905,6 +2913,10 @@ "default": "Default", "legacy": "Legacy" }, + "chat_hold_kill_actions": { + "kill_snapchat": "Kill Snapchat", + "kill_purrfectsnap": "Kill PurrfectSnap" + }, "path_format": { "create_author_folder": "Create folder for each author", "create_source_folder": "Create folder for each media source type", @@ -3623,6 +3635,7 @@ "cancel": "Cancel", "copy": "Copy", "save": "Save", + "set": "Set", "open": "Open", "download": "Download", "import": "Import", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index d7f3f223..d2d24343 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -3,6 +3,8 @@ package me.eternal.purrfectsnap.common.config.impl import me.eternal.purrfectsnap.common.config.ConfigContainer import me.eternal.purrfectsnap.common.config.ConfigFlag import me.eternal.purrfectsnap.common.config.FeatureNotice +import java.time.LocalDate +import java.time.format.DateTimeFormatter class Global : ConfigContainer() { companion object { @@ -46,6 +48,12 @@ class Global : ConfigContainer() { val betterLocation = container("better_location", BetterLocationConfig()) val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() } + val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "") { + requireRestart() + inputCheck = { + it.isBlank() || runCatching { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }.isSuccess + } + } val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig()) val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply { profile.set("max") diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index c0bcdd0a..a07ec875 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -63,6 +63,7 @@ class UserInterfaceTweaks : ConfigContainer() { } val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() } val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") } + val chatHoldKillActions = multiple("chat_hold_kill_actions", "kill_snapchat", "kill_purrfectsnap") { requireRestart() } inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) { val customSnapScore = string("custom_snap_score") { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt index 048e9ad1..aa5fbb98 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt @@ -8,9 +8,11 @@ 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.mapper.impl.PlusSubscriptionMapper +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter class SnapchatPlus: Feature("SnapchatPlus") { - private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L) private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L) override fun init() { @@ -40,7 +42,24 @@ class SnapchatPlus: Feature("SnapchatPlus") { //subscription status set(statusField.getAsString()!!, 2) - set(originalSubscriptionTimeMillisField.getAsString()!!, originalSubscriptionTime) + val fallbackOriginalSubscriptionTime = System.currentTimeMillis() - 7776000000L + val customPurchaseDate = context.config.global.snapchatPlusPurchaseDate.get().trim() + val customPurchaseDateMillis = if (customPurchaseDate.isNotEmpty()) { + runCatching { + LocalDate + .parse(customPurchaseDate, DateTimeFormatter.ISO_LOCAL_DATE) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() + } else { + null + } + + set( + originalSubscriptionTimeMillisField.getAsString()!!, + customPurchaseDateMillis ?: fallbackOriginalSubscriptionTime + ) set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis) } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt index 09240a3c..c51e1d83 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt @@ -1,9 +1,12 @@ package me.eternal.purrfectsnap.core.ui.menu.impl +import android.app.ActivityManager +import android.os.Process import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView +import me.eternal.purrfectsnap.common.Constants import me.eternal.purrfectsnap.common.ui.OverlayType import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu @@ -47,6 +50,9 @@ class SettingsGearInjector : AbstractMenu() { this@SettingsGearInjector.context.log.info("Gear icon clicked.", logTag) this@SettingsGearInjector.context.bridgeClient.openOverlay(OverlayType.SETTINGS) } + setOnLongClickListener { + this@SettingsGearInjector.handleChatHoldKillAction() + } } val layoutParams = FrameLayout.LayoutParams( @@ -88,4 +94,24 @@ class SettingsGearInjector : AbstractMenu() { } } } + + private fun handleChatHoldKillAction(): Boolean { + val selectedActions = context.config.userInterface.chatHoldKillActions.get() + if (selectedActions.isEmpty()) return false + + context.mainActivity?.vibrateLongPress() + + if (selectedActions.contains("kill_purrfectsnap")) { + runCatching { + val activityManager = context.androidContext.getSystemService(ActivityManager::class.java) + activityManager?.killBackgroundProcesses(Constants.MODULE_PACKAGE_NAME) + } + } + + if (selectedActions.contains("kill_snapchat")) { + Process.killProcess(Process.myPid()) + } + + return true + } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt index f9052bf0..312e638f 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt @@ -1,7 +1,10 @@ package me.eternal.purrfectsnap.core.ui.menu.impl +import android.app.ActivityManager +import android.os.Process import android.view.View import android.widget.FrameLayout +import me.eternal.purrfectsnap.common.Constants import me.eternal.purrfectsnap.common.ui.OverlayType import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu import me.eternal.purrfectsnap.core.util.hook.HookStage @@ -22,8 +25,31 @@ class SettingsMenu : AbstractMenu() { view.setOnClickListener { context.bridgeClient.openOverlay(OverlayType.SETTINGS) } + view.setOnLongClickListener { + handleChatHoldKillAction() + } } } } } + + private fun handleChatHoldKillAction(): Boolean { + val selectedActions = context.config.userInterface.chatHoldKillActions.get() + if (selectedActions.isEmpty()) return false + + context.mainActivity?.vibrateLongPress() + + if (selectedActions.contains("kill_purrfectsnap")) { + runCatching { + val activityManager = context.androidContext.getSystemService(ActivityManager::class.java) + activityManager?.killBackgroundProcesses(Constants.MODULE_PACKAGE_NAME) + } + } + + if (selectedActions.contains("kill_snapchat")) { + Process.killProcess(Process.myPid()) + } + + return true + } } From 2faa3687226758b3e46e46a8e3703951e0f6d99a Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:43:15 +0000 Subject: [PATCH 19/33] Merge pull request #5 from imCrest/imCrest/add-customizable-purchase-date-feature-7fai8g --- .../purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt | 2 ++ .../me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt index c51e1d83..e1907f3e 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsGearInjector.kt @@ -12,6 +12,7 @@ import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu import me.eternal.purrfectsnap.core.util.ktx.getDrawable import me.eternal.purrfectsnap.core.util.ktx.getStyledAttributes +import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress class SettingsGearInjector : AbstractMenu() { private val hovaHeaderAddFriendIconId by lazy { @@ -99,6 +100,7 @@ class SettingsGearInjector : AbstractMenu() { val selectedActions = context.config.userInterface.chatHoldKillActions.get() if (selectedActions.isEmpty()) return false + context.androidContext.vibrateLongPress() context.mainActivity?.vibrateLongPress() if (selectedActions.contains("kill_purrfectsnap")) { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt index 312e638f..9756114a 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/SettingsMenu.kt @@ -10,6 +10,7 @@ import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.hook import me.eternal.purrfectsnap.core.util.ktx.getId +import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress class SettingsMenu : AbstractMenu() { private val hovaHeaderSearchIconId by lazy { @@ -37,6 +38,7 @@ class SettingsMenu : AbstractMenu() { val selectedActions = context.config.userInterface.chatHoldKillActions.get() if (selectedActions.isEmpty()) return false + context.androidContext.vibrateLongPress() context.mainActivity?.vibrateLongPress() if (selectedActions.contains("kill_purrfectsnap")) { From 73ed1a9647fabc04498bd583c62bbdc51f48a431 Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:56:23 +0000 Subject: [PATCH 20/33] Fix disable spotlight to affect home tab without breaking chat media --- .../features/impl/ConfigurationOverride.kt | 2 -- .../core/features/impl/ui/UITweaks.kt | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) 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 715fa180..70c27dbd 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 @@ -165,8 +165,6 @@ class ConfigurationOverride : Feature("Configuration Override") { overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() }, { true }, isAppExperiment = true) - overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() }, - { false }) overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, { true }) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index abea54cf..1636a856 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -73,6 +73,7 @@ class UITweaks : Feature("UITweaks") { val blockAds by context.config.global.blockAds val hiddenElements by context.config.userInterface.hideUiComponents val hideStorySuggestions by context.config.userInterface.hideStorySuggestions + val disableSpotlight by context.config.userInterface.disableSpotlight val isImmersiveCamera by context.config.camera.immersiveCameraPreview val displayMetrics = context.resources.displayMetrics @@ -80,6 +81,12 @@ class UITweaks : Feature("UITweaks") { val chatNoteRecordButton = getId("chat_note_record_button", "id") val unreadHintButton = getId("unread_hint_button", "id") + val spotlightNavIds = listOf( + getId("hova_nav_spotlight", "id"), + getId("ngs_hova_nav_spotlight", "id"), + getId("hova_nav_spotlight_tab", "id"), + getId("hova_nav_spotlight_button", "id") + ).filter { it != 0 }.toSet() Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook( HookStage.AFTER, @@ -118,6 +125,21 @@ class UITweaks : Feature("UITweaks") { hideStorySection(event) } + if (disableSpotlight) { + val resourceEntryName = runCatching { context.resources.getResourceEntryName(viewId) }.getOrNull() + val isSpotlightNavById = viewId in spotlightNavIds + val isSpotlightNavByName = resourceEntryName?.let { + it.contains("spotlight", ignoreCase = true) && + (it.contains("hova_nav", ignoreCase = true) || it.contains("bottom_nav", ignoreCase = true)) + } == true + + if (isSpotlightNavById || isSpotlightNavByName) { + view.hideViewCompletely() + event.canceled = true + return@subscribe + } + } + if (isImmersiveCamera) { if (view.id == getId("edits_container", "id")) { Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) { From 81796c8824fe1feb4ba2d3b7d5e69dbb2d536993 Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:20:03 +0000 Subject: [PATCH 21/33] Refine spotlight-tab hiding to avoid friend-feed media breakage --- .../purrfectsnap/core/features/impl/ui/UITweaks.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index 1636a856..2e4d37bd 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -127,6 +127,20 @@ class UITweaks : Feature("UITweaks") { if (disableSpotlight) { val resourceEntryName = runCatching { context.resources.getResourceEntryName(viewId) }.getOrNull() + val parentClassName = event.parent.javaClass.name + val isNavigationParent = parentClassName.contains("hova", ignoreCase = true) && + (parentClassName.contains("nav", ignoreCase = true) || parentClassName.contains("tab", ignoreCase = true)) + val contentDescription = view.contentDescription?.toString() + val isSpotlightNavById = viewId in spotlightNavIds + val isSpotlightNavByName = resourceEntryName?.let { + it.contains("spotlight", ignoreCase = true) && + (it.contains("nav", ignoreCase = true) || it.contains("tab", ignoreCase = true)) + } == true + val isSpotlightNavByContentDescription = isNavigationParent && + contentDescription?.contains("spotlight", ignoreCase = true) == true + + if (isSpotlightNavById || isSpotlightNavByName || isSpotlightNavByContentDescription) { + view.hideViewCompletely() val isSpotlightNavById = viewId in spotlightNavIds val isSpotlightNavByName = resourceEntryName?.let { it.contains("spotlight", ignoreCase = true) && From 4a17989c96e1d02ed32b2f25ce3862227c78b9db Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:36:46 +0000 Subject: [PATCH 22/33] Narrow spotlight tab hiding to navigation ids only --- .../purrfectsnap/core/features/impl/ui/UITweaks.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index 2e4d37bd..b3d6d627 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -87,6 +87,12 @@ class UITweaks : Feature("UITweaks") { getId("hova_nav_spotlight_tab", "id"), getId("hova_nav_spotlight_button", "id") ).filter { it != 0 }.toSet() + val spotlightNavNames = setOf( + "hova_nav_spotlight", + "ngs_hova_nav_spotlight", + "hova_nav_spotlight_tab", + "hova_nav_spotlight_button" + ) Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook( HookStage.AFTER, @@ -130,6 +136,11 @@ class UITweaks : Feature("UITweaks") { val parentClassName = event.parent.javaClass.name val isNavigationParent = parentClassName.contains("hova", ignoreCase = true) && (parentClassName.contains("nav", ignoreCase = true) || parentClassName.contains("tab", ignoreCase = true)) + val isSpotlightNavById = viewId in spotlightNavIds + val isSpotlightNavByName = resourceEntryName != null && spotlightNavNames.contains(resourceEntryName) + + if (isNavigationParent && (isSpotlightNavById || isSpotlightNavByName)) { + view.hideViewCompletely() val contentDescription = view.contentDescription?.toString() val isSpotlightNavById = viewId in spotlightNavIds val isSpotlightNavByName = resourceEntryName?.let { From 360540edb4f0687b5a7461a5c8b5d0d79090a41e Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:57:34 +0000 Subject: [PATCH 23/33] Refactor spotlight nav filter helper to avoid parsing/side-effect issues --- .../core/features/impl/ui/UITweaks.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index b3d6d627..19212158 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -69,6 +69,21 @@ class UITweaks : Feature("UITweaks") { } } + private fun shouldHideSpotlightNav( + event: AddViewEvent, + spotlightNavIds: Set, + spotlightNavNames: Set + ): Boolean { + val viewId = event.view.id + val resourceEntryName = runCatching { context.resources.getResourceEntryName(viewId) }.getOrNull() + val parentClassName = event.parent.javaClass.name + val isNavigationParent = parentClassName.contains("hova", ignoreCase = true) && + (parentClassName.contains("nav", ignoreCase = true) || parentClassName.contains("tab", ignoreCase = true)) + val isSpotlightNavById = viewId in spotlightNavIds + val isSpotlightNavByName = resourceEntryName != null && spotlightNavNames.contains(resourceEntryName) + return isNavigationParent && (isSpotlightNavById || isSpotlightNavByName) + } + private fun onActivityCreate() { val blockAds by context.config.global.blockAds val hiddenElements by context.config.userInterface.hideUiComponents @@ -132,6 +147,8 @@ class UITweaks : Feature("UITweaks") { } if (disableSpotlight) { + if (shouldHideSpotlightNav(event, spotlightNavIds, spotlightNavNames)) { + view.hideViewCompletely() val resourceEntryName = runCatching { context.resources.getResourceEntryName(viewId) }.getOrNull() val parentClassName = event.parent.javaClass.name val isNavigationParent = parentClassName.contains("hova", ignoreCase = true) && From 992a48fa8198663b6449e3d2d56a11a28a7576cb Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:08:57 +0000 Subject: [PATCH 24/33] Stabilize UITweaks block structure for CI Kotlin parsing --- .../eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index 19212158..4f1817ee 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -262,12 +262,12 @@ class UITweaks : Feature("UITweaks") { if (viewId == unreadHintButton && hiddenElements.contains("hide_unread_chat_hint")) { event.canceled = true } - } + } // end AddViewEvent subscription } override fun init() { onNextActivityCreate { onActivityCreate() } - } -} + } // end init +} // end UITweaks From 98f0db6a1536268dd88e0d3a81d0f526053324fe Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:17:26 +0000 Subject: [PATCH 25/33] Harden macOS native build by clearing DYLD override vars --- native/build-native.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/native/build-native.sh b/native/build-native.sh index 151f4872..be73d6b3 100644 --- a/native/build-native.sh +++ b/native/build-native.sh @@ -338,4 +338,14 @@ case "$1" in esac cd "$RUST_DIR" + +# macOS CI runners may inject DYLD override variables that break Rust/cargo +# processes with libc++abi symbol shim errors and bus error 10. +if [[ "$HOST_TAG" == darwin-* ]]; then + unset DYLD_INSERT_LIBRARIES + unset DYLD_LIBRARY_PATH + unset DYLD_FRAMEWORK_PATH + unset DYLD_ROOT_PATH +fi + rustup run "$TOOLCHAIN" cargo build --release --target "$1" From 3dccadc5c01d694579091c4049d7e8c9be2ce608 Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:42:06 +0000 Subject: [PATCH 26/33] Sync upstream --- .../core/features/impl/ui/UITweaks.kt | 111 ++++++++---------- 1 file changed, 50 insertions(+), 61 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index 4f1817ee..f9c7f3ab 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -71,17 +71,20 @@ class UITweaks : Feature("UITweaks") { private fun shouldHideSpotlightNav( event: AddViewEvent, - spotlightNavIds: Set, - spotlightNavNames: Set + spotlightNavIds: Set ): Boolean { val viewId = event.view.id + if (viewId in spotlightNavIds) return true + + // Keep the fallback scoped to home/bottom navigation resource names so + // chat media viewers and spotlight-related content surfaces still open. val resourceEntryName = runCatching { context.resources.getResourceEntryName(viewId) }.getOrNull() - val parentClassName = event.parent.javaClass.name - val isNavigationParent = parentClassName.contains("hova", ignoreCase = true) && - (parentClassName.contains("nav", ignoreCase = true) || parentClassName.contains("tab", ignoreCase = true)) - val isSpotlightNavById = viewId in spotlightNavIds - val isSpotlightNavByName = resourceEntryName != null && spotlightNavNames.contains(resourceEntryName) - return isNavigationParent && (isSpotlightNavById || isSpotlightNavByName) + ?: return false + return resourceEntryName.contains("spotlight", ignoreCase = true) && + ( + resourceEntryName.contains("hova_nav", ignoreCase = true) || + resourceEntryName.contains("bottom_nav", ignoreCase = true) + ) } private fun onActivityCreate() { @@ -102,20 +105,16 @@ class UITweaks : Feature("UITweaks") { getId("hova_nav_spotlight_tab", "id"), getId("hova_nav_spotlight_button", "id") ).filter { it != 0 }.toSet() - val spotlightNavNames = setOf( - "hova_nav_spotlight", - "ngs_hova_nav_spotlight", - "hova_nav_spotlight_tab", - "hova_nav_spotlight_button" - ) - Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook( + Resources::class.java.methods.first { it.name == "getDimensionPixelSize" }.hook( HookStage.AFTER, { isImmersiveCamera } ) { param -> val id = param.arg(0) - if (id == getId("capri_viewfinder_default_corner_radius", "dimen") || - id == getId("ngs_hova_nav_larger_camera_button_size", "dimen")) { + if ( + id == getId("capri_viewfinder_default_corner_radius", "dimen") || + id == getId("ngs_hova_nav_larger_camera_button_size", "dimen") + ) { param.setResult(0) } } @@ -124,12 +123,17 @@ class UITweaks : Feature("UITweaks") { if (event.view is FrameLayout) { fun removeView() { event.view.layoutParams = event.view.layoutParams?.apply { - width = 0; height = 0 + width = 0 + height = 0 } ?: return } val viewModelString = event.prevModel.toString() - val isMyStory by lazy { viewModelString.let { it.startsWith("StoryCarouselItemViewModel") && it.contains("storyId=") } } + val isMyStory by lazy { + viewModelString.let { + it.startsWith("StoryCarouselItemViewModel") && it.contains("storyId=") + } + } if (hideStorySuggestions.contains("hide_my_stories") && isMyStory) { removeView() @@ -146,40 +150,10 @@ class UITweaks : Feature("UITweaks") { hideStorySection(event) } - if (disableSpotlight) { - if (shouldHideSpotlightNav(event, spotlightNavIds, spotlightNavNames)) { - view.hideViewCompletely() - val resourceEntryName = runCatching { context.resources.getResourceEntryName(viewId) }.getOrNull() - val parentClassName = event.parent.javaClass.name - val isNavigationParent = parentClassName.contains("hova", ignoreCase = true) && - (parentClassName.contains("nav", ignoreCase = true) || parentClassName.contains("tab", ignoreCase = true)) - val isSpotlightNavById = viewId in spotlightNavIds - val isSpotlightNavByName = resourceEntryName != null && spotlightNavNames.contains(resourceEntryName) - - if (isNavigationParent && (isSpotlightNavById || isSpotlightNavByName)) { - view.hideViewCompletely() - val contentDescription = view.contentDescription?.toString() - val isSpotlightNavById = viewId in spotlightNavIds - val isSpotlightNavByName = resourceEntryName?.let { - it.contains("spotlight", ignoreCase = true) && - (it.contains("nav", ignoreCase = true) || it.contains("tab", ignoreCase = true)) - } == true - val isSpotlightNavByContentDescription = isNavigationParent && - contentDescription?.contains("spotlight", ignoreCase = true) == true - - if (isSpotlightNavById || isSpotlightNavByName || isSpotlightNavByContentDescription) { - view.hideViewCompletely() - val isSpotlightNavById = viewId in spotlightNavIds - val isSpotlightNavByName = resourceEntryName?.let { - it.contains("spotlight", ignoreCase = true) && - (it.contains("hova_nav", ignoreCase = true) || it.contains("bottom_nav", ignoreCase = true)) - } == true - - if (isSpotlightNavById || isSpotlightNavByName) { - view.hideViewCompletely() - event.canceled = true - return@subscribe - } + if (disableSpotlight && shouldHideSpotlightNav(event, spotlightNavIds)) { + view.hideViewCompletely() + event.canceled = true + return@subscribe } if (isImmersiveCamera) { @@ -198,7 +172,10 @@ class UITweaks : Feature("UITweaks") { } } - if (hiddenElements.contains("hide_billboard_prompt") && event.parent.javaClass.name.endsWith("BillboardFeedHeaderPromptComponent")) { + if ( + hiddenElements.contains("hide_billboard_prompt") && + event.parent.javaClass.name.endsWith("BillboardFeedHeaderPromptComponent") + ) { hideView(event.parent) view.getValdiContext()?.componentContext?.get()?.dataBuilder { val dismissFunction = get("_onDismiss") ?: return@subscribe @@ -206,7 +183,11 @@ class UITweaks : Feature("UITweaks") { } } - if (event.parent.javaClass.name.endsWith("ConstraintLayout") && event.view is LinearLayout && hiddenElements.contains("hide_map_reactions")) { + if ( + event.parent.javaClass.name.endsWith("ConstraintLayout") && + event.view is LinearLayout && + hiddenElements.contains("hide_map_reactions") + ) { val viewGroup = event.view as ViewGroup val children = viewGroup.children() @@ -218,7 +199,10 @@ class UITweaks : Feature("UITweaks") { } } - if (event.parent.javaClass.name.endsWith("PreviewBottomToolbarView") && hiddenElements.contains("hide_post_to_story_buttons")) { + if ( + event.parent.javaClass.name.endsWith("PreviewBottomToolbarView") && + hiddenElements.contains("hide_post_to_story_buttons") + ) { if (event.parent.childCount == 1) { event.view.hideViewCompletely() } @@ -227,7 +211,8 @@ class UITweaks : Feature("UITweaks") { if (viewId == getId("send_btn", "id") && hiddenElements.contains("hide_post_to_story_buttons")) { // hide previous view if (event.parent.childCount > 0) { - val lastChild = event.parent.getChildAt(event.parent.childCount - 1)?.takeIf { it is LinearLayout } ?: return@subscribe + val lastChild = event.parent.getChildAt(event.parent.childCount - 1) + ?.takeIf { it is LinearLayout } ?: return@subscribe context.log.verbose("Hiding post to story button") lastChild.hideViewCompletely() } @@ -238,7 +223,11 @@ class UITweaks : Feature("UITweaks") { if (hiddenElements.contains("hide_live_location_share_button")) { chatInputBar?.onLayoutChange { - chatInputBar!!.children().lastOrNull { it.javaClass.name.endsWith("AppCompatImageButton") && runCatching { it.resources.getResourceName(it.id) }.getOrNull() == null } + chatInputBar!!.children() + .lastOrNull { + it.javaClass.name.endsWith("AppCompatImageButton") && + runCatching { it.resources.getResourceName(it.id) }.getOrNull() == null + } ?.hideViewCompletely() } } @@ -262,12 +251,12 @@ class UITweaks : Feature("UITweaks") { if (viewId == unreadHintButton && hiddenElements.contains("hide_unread_chat_hint")) { event.canceled = true } - } // end AddViewEvent subscription + } } override fun init() { onNextActivityCreate { onActivityCreate() } - } // end init -} // end UITweaks + } +} From 9223d2debcfee49a4e2afd103fcc7ea32e57e20d Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:13:24 +0000 Subject: [PATCH 27/33] fix-disable-spotlight-chat-media --- build.gradle.kts | 4 +- changelogs-stable.txt | 11 +++++ .../core/features/impl/ui/UITweaks.kt | 44 ++++++++++++++----- gradle.properties | 4 +- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 08ac65ef..c43112cf 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.8").get()) -rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("324").get().toInt()) +rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.9").get()) +rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("325").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 b2457893..05fca07a 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -1,3 +1,14 @@ +## v1.6.9 +- New: Updated the Stealth mode for better visibility with the chat stealth mode (keeps chats from being read), and snap stealth-mode and full stealth mode toggle (normal stealth-mode). (tq Javalsta) +- Fix: Fixed performance mode profile save/load so Disabled persists correctly and no longer falls back to Max mode on app restart. (tq schrodingerspet) +- Fix: Fixed the resume/reopen UI break when max performance mode is turned on, and other bug fixes. (tq schrodingerspet) +- New: Implemented an Auto Open Stop Button directly within the notification card. +- New: Added a log filter menu in logs page to isolate Auto-Open, Media downloads, friend tracker, and Core logs. +- Fix: Completely rewritten Auto Open Engine to optimize the auto open engine. +- Fix: Fixed Batch Story Download feature not working. +- FIx: Minor bug fixes for media downloader in stories and spotlight. +- Fix: Bug fixes to improve custom emojis stability. + ## 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 diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index f9c7f3ab..d83009aa 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -71,20 +71,38 @@ class UITweaks : Feature("UITweaks") { private fun shouldHideSpotlightNav( event: AddViewEvent, - spotlightNavIds: Set + spotlightNavIds: Set, + spotlightNavNames: Set ): Boolean { + fun resourceEntryNameOrNull(id: Int): String? { + if (id == View.NO_ID || id == 0) return null + return runCatching { context.resources.getResourceEntryName(id) }.getOrNull() + } + val viewId = event.view.id - if (viewId in spotlightNavIds) return true + val parentId = event.parent.id + if (viewId in spotlightNavIds || parentId in spotlightNavIds) return true + + val resourceNames = listOfNotNull( + resourceEntryNameOrNull(viewId), + resourceEntryNameOrNull(parentId) + ) + + if (resourceNames.any { it in spotlightNavNames }) return true // Keep the fallback scoped to home/bottom navigation resource names so // chat media viewers and spotlight-related content surfaces still open. - val resourceEntryName = runCatching { context.resources.getResourceEntryName(viewId) }.getOrNull() - ?: return false - return resourceEntryName.contains("spotlight", ignoreCase = true) && - ( - resourceEntryName.contains("hova_nav", ignoreCase = true) || - resourceEntryName.contains("bottom_nav", ignoreCase = true) - ) + val parentClassName = event.parent.javaClass.name + val isHomeNavigationContainer = parentClassName.contains("hova", ignoreCase = true) && + (parentClassName.contains("nav", ignoreCase = true) || parentClassName.contains("tab", ignoreCase = true)) + + return isHomeNavigationContainer && resourceNames.any { resourceEntryName -> + resourceEntryName.contains("spotlight", ignoreCase = true) && + ( + resourceEntryName.contains("hova_nav", ignoreCase = true) || + resourceEntryName.contains("bottom_nav", ignoreCase = true) + ) + } } private fun onActivityCreate() { @@ -105,6 +123,12 @@ class UITweaks : Feature("UITweaks") { getId("hova_nav_spotlight_tab", "id"), getId("hova_nav_spotlight_button", "id") ).filter { it != 0 }.toSet() + val spotlightNavNames = setOf( + "hova_nav_spotlight", + "ngs_hova_nav_spotlight", + "hova_nav_spotlight_tab", + "hova_nav_spotlight_button" + ) Resources::class.java.methods.first { it.name == "getDimensionPixelSize" }.hook( HookStage.AFTER, @@ -150,7 +174,7 @@ class UITweaks : Feature("UITweaks") { hideStorySection(event) } - if (disableSpotlight && shouldHideSpotlightNav(event, spotlightNavIds)) { + if (disableSpotlight && shouldHideSpotlightNav(event, spotlightNavIds, spotlightNavNames)) { view.hideViewCompletely() event.canceled = true return@subscribe diff --git a/gradle.properties b/gradle.properties index 839e6e23..5960e246 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.8 -APP_VERSION_CODE=324 +APP_VERSION_NAME=1.6.9 +APP_VERSION_CODE=325 debug_build_hash=18fe2a814d0e2eb5 psIntegrityPinnedSha256= EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c From 8e1628e28c33e04964f399824ae75166786be063 Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:02:43 +0000 Subject: [PATCH 28/33] Merge pull request #15 from imCrest/imCrest/fix-disable-spotlight-feed-tab --- .../purrfectsnap/core/features/impl/ui/UITweaks.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index d83009aa..0362baea 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -92,15 +92,12 @@ class UITweaks : Feature("UITweaks") { // Keep the fallback scoped to home/bottom navigation resource names so // chat media viewers and spotlight-related content surfaces still open. - val parentClassName = event.parent.javaClass.name - val isHomeNavigationContainer = parentClassName.contains("hova", ignoreCase = true) && - (parentClassName.contains("nav", ignoreCase = true) || parentClassName.contains("tab", ignoreCase = true)) - - return isHomeNavigationContainer && resourceNames.any { resourceEntryName -> + return resourceNames.any { resourceEntryName -> resourceEntryName.contains("spotlight", ignoreCase = true) && ( resourceEntryName.contains("hova_nav", ignoreCase = true) || - resourceEntryName.contains("bottom_nav", ignoreCase = true) + resourceEntryName.contains("bottom_nav", ignoreCase = true) || + resourceEntryName.contains("nav", ignoreCase = true) ) } } From 73616f2158af13b9af498a5275532ac773a2ffa9 Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:25:16 +0000 Subject: [PATCH 29/33] Sync: Update from upstream --- .../core/features/impl/ui/UITweaks.kt | 44 +++++++++++++++---- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index 0362baea..ebf9690b 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -74,24 +74,47 @@ class UITweaks : Feature("UITweaks") { spotlightNavIds: Set, spotlightNavNames: Set ): Boolean { - fun resourceEntryNameOrNull(id: Int): String? { + fun resourceEntryNameOrNull(view: View): String? { + val id = view.id if (id == View.NO_ID || id == 0) return null return runCatching { context.resources.getResourceEntryName(id) }.getOrNull() } - val viewId = event.view.id - val parentId = event.parent.id - if (viewId in spotlightNavIds || parentId in spotlightNavIds) return true + val views = buildList { + var current: View? = event.view + repeat(5) { + current ?: return@repeat + add(current!!) + current = current?.parent as? View + } + } - val resourceNames = listOfNotNull( - resourceEntryNameOrNull(viewId), - resourceEntryNameOrNull(parentId) - ) + if (views.any { it.id in spotlightNavIds }) return true + + val resourceNames = views.mapNotNull(::resourceEntryNameOrNull) if (resourceNames.any { it in spotlightNavNames }) return true // Keep the fallback scoped to home/bottom navigation resource names so // chat media viewers and spotlight-related content surfaces still open. + val classNames = views.map { it.javaClass.name } + val contentDescriptions = views.mapNotNull { it.contentDescription?.toString() } + + val hasSpotlightMarker = resourceNames.any { it.contains("spotlight", ignoreCase = true) } || + contentDescriptions.any { it.contains("spotlight", ignoreCase = true) } + + if (!hasSpotlightMarker) return false + + return resourceNames.any { resourceEntryName -> + ( + resourceEntryName.contains("spotlight", ignoreCase = true) || + resourceEntryName.contains("following", ignoreCase = true) + ) && + ( + resourceEntryName.contains("hova_nav", ignoreCase = true) || + resourceEntryName.contains("bottom_nav", ignoreCase = true) || + resourceEntryName.contains("nav", ignoreCase = true) || + resourceEntryName.contains("tab", ignoreCase = true) return resourceNames.any { resourceEntryName -> resourceEntryName.contains("spotlight", ignoreCase = true) && ( @@ -99,6 +122,11 @@ class UITweaks : Feature("UITweaks") { resourceEntryName.contains("bottom_nav", ignoreCase = true) || resourceEntryName.contains("nav", ignoreCase = true) ) + } || classNames.any { className -> + className.contains("navigation", ignoreCase = true) || + className.contains("bottom", ignoreCase = true) || + className.contains("tab", ignoreCase = true) || + className.contains("hova", ignoreCase = true) } } From cd8d358fa62782c7c8e9086cfc3fa679cea4bce1 Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:03:36 +0000 Subject: [PATCH 30/33] Sync: Update from upstream --- .../core/features/impl/ui/UITweaks.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index ebf9690b..9c8a847c 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -105,6 +105,16 @@ class UITweaks : Feature("UITweaks") { if (!hasSpotlightMarker) return false + val matchesNavigationName = resourceNames.any { resourceEntryName -> + val isSpotlightTabName = + resourceEntryName.contains("spotlight", ignoreCase = true) || + resourceEntryName.contains("following", ignoreCase = true) + val isNavigationName = + resourceEntryName.contains("hova_nav", ignoreCase = true) || + resourceEntryName.contains("bottom_nav", ignoreCase = true) || + resourceEntryName.contains("nav", ignoreCase = true) || + resourceEntryName.contains("tab", ignoreCase = true) + isSpotlightTabName && isNavigationName return resourceNames.any { resourceEntryName -> ( resourceEntryName.contains("spotlight", ignoreCase = true) || @@ -128,6 +138,15 @@ class UITweaks : Feature("UITweaks") { className.contains("tab", ignoreCase = true) || className.contains("hova", ignoreCase = true) } + + val matchesNavigationClass = classNames.any { className -> + className.contains("navigation", ignoreCase = true) || + className.contains("bottom", ignoreCase = true) || + className.contains("tab", ignoreCase = true) || + className.contains("hova", ignoreCase = true) + } + + return matchesNavigationName || matchesNavigationClass } private fun onActivityCreate() { From 10a820e93ec9e2d1e4e096a62882ee8f0445006e Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:16:08 +0000 Subject: [PATCH 31/33] Sync: Update from upstream --- .../core/features/impl/ui/UITweaks.kt | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index 9c8a847c..c3cd7fc5 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -115,28 +115,6 @@ class UITweaks : Feature("UITweaks") { resourceEntryName.contains("nav", ignoreCase = true) || resourceEntryName.contains("tab", ignoreCase = true) isSpotlightTabName && isNavigationName - return resourceNames.any { resourceEntryName -> - ( - resourceEntryName.contains("spotlight", ignoreCase = true) || - resourceEntryName.contains("following", ignoreCase = true) - ) && - ( - resourceEntryName.contains("hova_nav", ignoreCase = true) || - resourceEntryName.contains("bottom_nav", ignoreCase = true) || - resourceEntryName.contains("nav", ignoreCase = true) || - resourceEntryName.contains("tab", ignoreCase = true) - return resourceNames.any { resourceEntryName -> - resourceEntryName.contains("spotlight", ignoreCase = true) && - ( - resourceEntryName.contains("hova_nav", ignoreCase = true) || - resourceEntryName.contains("bottom_nav", ignoreCase = true) || - resourceEntryName.contains("nav", ignoreCase = true) - ) - } || classNames.any { className -> - className.contains("navigation", ignoreCase = true) || - className.contains("bottom", ignoreCase = true) || - className.contains("tab", ignoreCase = true) || - className.contains("hova", ignoreCase = true) } val matchesNavigationClass = classNames.any { className -> From 7005fdf5671950ccedd6bab241967dd234cc87de Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:34:24 +0000 Subject: [PATCH 32/33] Sync: Update from upstream --- .../core/features/impl/ui/UITweaks.kt | 114 ++++++++++++------ 1 file changed, 77 insertions(+), 37 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index c3cd7fc5..7cb52d54 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -6,6 +6,7 @@ import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.widget.FrameLayout import android.widget.LinearLayout +import android.widget.TextView import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent import me.eternal.purrfectsnap.core.features.Feature @@ -69,62 +70,90 @@ class UITweaks : Feature("UITweaks") { } } - private fun shouldHideSpotlightNav( + private fun findSpotlightNavTarget( event: AddViewEvent, spotlightNavIds: Set, spotlightNavNames: Set - ): Boolean { + ): View? { + data class ViewMetadata( + val view: View, + val resourceEntryName: String?, + val contentDescription: String?, + val text: String?, + val className: String + ) + fun resourceEntryNameOrNull(view: View): String? { val id = view.id if (id == View.NO_ID || id == 0) return null return runCatching { context.resources.getResourceEntryName(id) }.getOrNull() } - val views = buildList { + val markerKeywords = setOf("spotlight", "following", "discover") + + val viewChain = buildList { var current: View? = event.view repeat(5) { current ?: return@repeat - add(current!!) + add( + ViewMetadata( + view = current!!, + resourceEntryName = resourceEntryNameOrNull(current!!), + contentDescription = current!!.contentDescription?.toString(), + text = (current as? TextView)?.text?.toString(), + className = current!!.javaClass.name + ) + ) current = current?.parent as? View } } - if (views.any { it.id in spotlightNavIds }) return true - - val resourceNames = views.mapNotNull(::resourceEntryNameOrNull) - - if (resourceNames.any { it in spotlightNavNames }) return true - - // Keep the fallback scoped to home/bottom navigation resource names so - // chat media viewers and spotlight-related content surfaces still open. - val classNames = views.map { it.javaClass.name } - val contentDescriptions = views.mapNotNull { it.contentDescription?.toString() } - - val hasSpotlightMarker = resourceNames.any { it.contains("spotlight", ignoreCase = true) } || - contentDescriptions.any { it.contains("spotlight", ignoreCase = true) } - - if (!hasSpotlightMarker) return false - - val matchesNavigationName = resourceNames.any { resourceEntryName -> - val isSpotlightTabName = - resourceEntryName.contains("spotlight", ignoreCase = true) || - resourceEntryName.contains("following", ignoreCase = true) - val isNavigationName = - resourceEntryName.contains("hova_nav", ignoreCase = true) || - resourceEntryName.contains("bottom_nav", ignoreCase = true) || - resourceEntryName.contains("nav", ignoreCase = true) || - resourceEntryName.contains("tab", ignoreCase = true) - isSpotlightTabName && isNavigationName + fun isExactMatch(metadata: ViewMetadata): Boolean { + return metadata.view.id in spotlightNavIds || + metadata.resourceEntryName in spotlightNavNames } - val matchesNavigationClass = classNames.any { className -> - className.contains("navigation", ignoreCase = true) || + fun hasMarker(metadata: ViewMetadata): Boolean { + return listOfNotNull( + metadata.resourceEntryName, + metadata.contentDescription, + metadata.text + ).any { value -> + markerKeywords.any { keyword -> + value.contains(keyword, ignoreCase = true) + } + } + } + + fun isNavigationLike(metadata: ViewMetadata): Boolean { + val resourceEntryName = metadata.resourceEntryName.orEmpty() + val className = metadata.className + return resourceEntryName.contains("hova_nav", ignoreCase = true) || + resourceEntryName.contains("bottom_nav", ignoreCase = true) || + resourceEntryName.contains("nav", ignoreCase = true) || + resourceEntryName.contains("tab", ignoreCase = true) || + className.contains("navigation", ignoreCase = true) || className.contains("bottom", ignoreCase = true) || className.contains("tab", ignoreCase = true) || className.contains("hova", ignoreCase = true) } - return matchesNavigationName || matchesNavigationClass + if (viewChain.none(::isExactMatch) && viewChain.none(::hasMarker)) { + return null + } + + var sawSpotlightMarker = false + viewChain.forEach { metadata -> + if (isExactMatch(metadata) || hasMarker(metadata)) { + sawSpotlightMarker = true + } + + if (sawSpotlightMarker && isNavigationLike(metadata)) { + return metadata.view + } + } + + return viewChain.firstOrNull(::isExactMatch)?.view } private fun onActivityCreate() { @@ -143,13 +172,21 @@ class UITweaks : Feature("UITweaks") { getId("hova_nav_spotlight", "id"), getId("ngs_hova_nav_spotlight", "id"), getId("hova_nav_spotlight_tab", "id"), - getId("hova_nav_spotlight_button", "id") + getId("hova_nav_spotlight_button", "id"), + getId("hova_nav_discover", "id"), + getId("ngs_hova_nav_discover", "id"), + getId("hova_nav_discover_tab", "id"), + getId("hova_nav_discover_button", "id") ).filter { it != 0 }.toSet() val spotlightNavNames = setOf( "hova_nav_spotlight", "ngs_hova_nav_spotlight", "hova_nav_spotlight_tab", - "hova_nav_spotlight_button" + "hova_nav_spotlight_button", + "hova_nav_discover", + "ngs_hova_nav_discover", + "hova_nav_discover_tab", + "hova_nav_discover_button" ) Resources::class.java.methods.first { it.name == "getDimensionPixelSize" }.hook( @@ -196,8 +233,11 @@ class UITweaks : Feature("UITweaks") { hideStorySection(event) } - if (disableSpotlight && shouldHideSpotlightNav(event, spotlightNavIds, spotlightNavNames)) { - view.hideViewCompletely() + findSpotlightNavTarget(event, spotlightNavIds, spotlightNavNames)?.takeIf { disableSpotlight }?.let { targetView -> + targetView.hideViewCompletely() + if (targetView !== view) { + view.hideViewCompletely() + } event.canceled = true return@subscribe } From 9c226d1bb9110e15ce8a22e13841780bd34cda2c Mon Sep 17 00:00:00 2001 From: imCrest <217463890+imCrest@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:03:43 +0000 Subject: [PATCH 33/33] Sync: Update from upstream --- .../core/features/impl/ui/UITweaks.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt index 7cb52d54..49f26605 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/UITweaks.kt @@ -156,6 +156,66 @@ class UITweaks : Feature("UITweaks") { return viewChain.firstOrNull(::isExactMatch)?.view } + private fun findSpotlightHeaderTabsTarget(view: View): View? { + fun collectTextLabels(current: View, depth: Int = 0, maxDepth: Int = 2): List { + if (depth > maxDepth) return emptyList() + + val ownText = listOfNotNull( + current.contentDescription?.toString(), + (current as? TextView)?.text?.toString() + ).filter { it.isNotBlank() } + + if (current !is ViewGroup) return ownText + + return ownText + current.children().flatMap { child -> + collectTextLabels(child, depth + 1, maxDepth) + } + } + + fun isHeaderMarkerText(value: String): Boolean { + return value.contains("spotlight", ignoreCase = true) || + value.contains("discover", ignoreCase = true) || + value.contains("following", ignoreCase = true) + } + + val candidateChain = buildList { + var current: View? = view + repeat(6) { + current ?: return@repeat + add(current!!) + current = current?.parent as? View + } + } + + candidateChain.forEach { candidate -> + val group = candidate as? ViewGroup ?: return@forEach + if (group.childCount !in 2..4) return@forEach + + val directMarkedChildren = group.children().count { child -> + collectTextLabels(child).any(::isHeaderMarkerText) + } + + if (directMarkedChildren < 2) return@forEach + + val texts = collectTextLabels(group) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + + val hasSpotlightOrDiscover = texts.any { + it.contains("spotlight", ignoreCase = true) || + it.contains("discover", ignoreCase = true) + } + val hasFollowing = texts.any { it.contains("following", ignoreCase = true) } + + if (hasSpotlightOrDiscover && hasFollowing) { + return group + } + } + + return null + } + private fun onActivityCreate() { val blockAds by context.config.global.blockAds val hiddenElements by context.config.userInterface.hideUiComponents @@ -225,6 +285,10 @@ class UITweaks : Feature("UITweaks") { } } + context.event.subscribe(BindViewEvent::class, { disableSpotlight }) { event -> + findSpotlightHeaderTabsTarget(event.view)?.hideViewCompletely() + } + context.event.subscribe(AddViewEvent::class) { event -> val viewId = event.view.id val view = event.view