Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f01b6c2f9a | ||
|
|
98f8d6ebea | ||
|
|
015226cb40 | ||
|
|
ad06b0dffb | ||
|
|
4b4d64e8eb | ||
|
|
3eec22c615 | ||
|
|
9a2e6065dc | ||
|
|
8e00c29a59 | ||
|
|
9ec9517ec5 | ||
|
|
e822fc20b4 | ||
|
|
f3c794fc47 |
@@ -1 +1 @@
|
||||
- Test
|
||||
- All users are recommended to use the new Performance Mode feature! Go to the features tab and then select global and select performance mode and set it to Max. Then force stop and reopen Snapchat and you will feel the difference i.e. Snapchat will feel a lot faster.
|
||||
|
||||
@@ -86,7 +86,7 @@ class AnnouncementCheckWorker(
|
||||
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder = NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(R.drawable.launcher_icon_monochrome)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("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.3").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("316").get().toInt())
|
||||
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.6").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("322").get().toInt())
|
||||
rootProject.ext.set("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.
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
## v1.6.6
|
||||
- Fix: Persistent auto open snap disabled notification(tq to Kaladin)
|
||||
- Fix: Crash issues for some devices
|
||||
- Fix: upload quality for snaps sent through send override
|
||||
- Fix: Refactored the notification card for Auto Open to remove the looping progress bar while the engine is in Monitoring stage(tq to Kaladin)
|
||||
- New: Implemented a "Pre Fetch snaps" toggle, when enabled this will pre-fetch the snaps in the auto open queue and load them for the engine to open them efficiently and thus reducing the "Failed to Open..." status(tq to Kaladin)
|
||||
- Fix: The Auto Open engine now only fires the status update if the status text, processed count, or queue structural state actually changes. This reduces system wake-ups during idle periods which helps in battery drain management(tq to Kaladin)
|
||||
- Fix: Disk Optimization: Previous versions wrote the auto open queue to the disk every 25 snaps, this update modified this to a 3-minute windowed save. This reduces Disk I/O by ~75%, keeping the device from overheating excessively during Auto Open processes(tq to Kaladin)
|
||||
|
||||
## v1.6.5
|
||||
- New: Performance mode will now be set to max by default!
|
||||
- Fix: Several optimizations to the performance mode feature which will make your snapchat experience more smooth!
|
||||
- Fix: Crash issues for some devices after enabling spoof
|
||||
- Fix: Notification Icon for the Announcements(tq to Kaladin)
|
||||
- Fix: Auto Open Engine not processing, stuck on monitor/retry/failed loop causing overheating of the devices(tq to Kaladin)
|
||||
- Fix: Refactor the Notification card for the Auto Open to remove dynamic progress bar to add the native progress bar(tq to Kaladin)
|
||||
- Fix: Auto Open Statistics have been refactored to now show the dynamic queue status(tq to Kaladin)
|
||||
- New: Auto Open Thermal Protection/Throttle toggle, when turned on the processing will be doubled down to reduce the temps of the device to maintain a study temp of 40 C and below(tq to Kaladin)
|
||||
|
||||
## v1.6.4
|
||||
- New: Performance mode feature!(Smooth & Max)
|
||||
- Fix: Failed to init feature Device Spoofer for some devices
|
||||
- Fix: Increase the default queue of auto open snaps
|
||||
- Fix: user_conversation error spam
|
||||
|
||||
## v1.6.3
|
||||
- Fix: Blank Global Settings page
|
||||
- New: Backup & Restore option for Randomized Device Profile feature!
|
||||
|
||||
@@ -1648,6 +1648,7 @@
|
||||
"status_monitoring": "Monitoring",
|
||||
"status_active": "Active",
|
||||
"status_paused": "Paused",
|
||||
"thermal_status_title": "Thermal Cooling (Throttled)",
|
||||
"processed_count": "Opened",
|
||||
"queue_size": "Queue",
|
||||
"action_reset": "Reset Statistics",
|
||||
@@ -1682,10 +1683,6 @@
|
||||
"name": "Auto Open Compact Notification",
|
||||
"description": "Use a smaller, single-line notification for status updates"
|
||||
},
|
||||
"show_progress_bar": {
|
||||
"name": "Show Progress Bar",
|
||||
"description": "Display a visual progress bar in the status notification"
|
||||
},
|
||||
"show_lifetime_stats": {
|
||||
"name": "Show Lifetime Statistics",
|
||||
"description": "Include the total number of snaps opened since installation in the notification"
|
||||
@@ -1694,7 +1691,13 @@
|
||||
"name": "Show Queue Preview",
|
||||
"description": "Show a list of the most recent snaps waiting in the queue (Expanded only)"
|
||||
},
|
||||
"thermal_protection": {
|
||||
"name": "Thermal Protection",
|
||||
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40°C to prevent overheating"
|
||||
},
|
||||
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
|
||||
"pre_fetch_snaps": { "name": "Pre-fetch Snaps", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
|
||||
"content_type_snap": "Snap",
|
||||
"only_when_idle": {
|
||||
"name": "Auto Open Schedule",
|
||||
"description": "Configure a specific time window where the engine will throttle its speed."
|
||||
@@ -1909,6 +1912,16 @@
|
||||
"name": "Disable Metrics",
|
||||
"description": "Blocks sending specific analytic data to Snapchat"
|
||||
},
|
||||
"performance_mode": {
|
||||
"name": "Performance Mode",
|
||||
"description": "Applies an app-wide speed profile for navigation, preview loading, background work, and camera responsiveness",
|
||||
"properties": {
|
||||
"performance_profile": {
|
||||
"name": "Performance Profile",
|
||||
"description": "Select how aggressively PurrfectSnap pushes app-wide performance tuning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disable_story_sections": {
|
||||
"name": "Disable Story Sections",
|
||||
"description": "Removes sections from the Stories page\nMay require a refresh to work properly"
|
||||
@@ -3018,7 +3031,8 @@
|
||||
"NOTE": "Audio Note",
|
||||
"SNAP": "Snap",
|
||||
"SAVEABLE_SNAP": "Saveable Snap",
|
||||
"null": "Snapchat Default"
|
||||
"null": "Snapchat Default",
|
||||
"multiple_media_toast": "You can only send one media at a time"
|
||||
},
|
||||
"strip_media_metadata": {
|
||||
"hide_caption_text": "Hide Caption Text",
|
||||
@@ -3065,6 +3079,11 @@
|
||||
"custom_image_upload_format": {
|
||||
"null": "Automatic"
|
||||
},
|
||||
"performance_profile": {
|
||||
"smooth": "Smooth",
|
||||
"max": "Max",
|
||||
"null": "Disabled"
|
||||
},
|
||||
"update_check_frequency": {
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
@@ -3748,6 +3767,7 @@
|
||||
"paused_message": "Processing paused. Queue preserved ({count} snaps)",
|
||||
"status_paused": "Paused",
|
||||
"status_monitoring": "Monitoring",
|
||||
"thermal_status_title": "Thermal Cooling (Throttled)",
|
||||
"status_active": "Active",
|
||||
"status_failed": "Failed to open {sender}",
|
||||
"status_retrying": "Retrying in background...",
|
||||
@@ -3866,7 +3886,6 @@
|
||||
"material3_strings": {
|
||||
"date_range_picker_start_headline": "From",
|
||||
"date_range_picker_end_headline": "To",
|
||||
"date_range_picker_title": "Select date range",
|
||||
"date_picker_switch_to_calendar_mode": "Calendar",
|
||||
"date_picker_switch_to_input_mode": "Input",
|
||||
"date_range_picker_scroll_to_previous_month": "Previous month",
|
||||
@@ -4350,10 +4369,3 @@
|
||||
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
|
||||
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -38,9 +38,18 @@ class Global : ConfigContainer() {
|
||||
val customUploadImageFormat = unique("custom_image_upload_format", "jpeg", "png", "webp") { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) }
|
||||
}
|
||||
|
||||
inner class PerformanceModeConfig : ConfigContainer() {
|
||||
val profile = unique("performance_profile", "smooth", "max") {
|
||||
requireRestart()
|
||||
}
|
||||
}
|
||||
|
||||
val betterLocation = container("better_location", BetterLocationConfig())
|
||||
val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() }
|
||||
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
|
||||
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply {
|
||||
profile.set("max")
|
||||
}
|
||||
val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "erase_message", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() }
|
||||
val disableMetrics = boolean("disable_metrics") { requireRestart() }
|
||||
val disableStorySections = multiple("disable_story_sections", "friends", "suggested_stories", "following", "discover") { requireRestart(); requireCleanCache() }
|
||||
|
||||
@@ -166,7 +166,7 @@ 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 = 10) {
|
||||
val queueSize = integer("queue_size", defaultValue = 1000) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
|
||||
}
|
||||
val retryAttempts = integer("retry_attempts", defaultValue = 5) {
|
||||
@@ -176,12 +176,13 @@ class MessagingTweaks : ConfigContainer() {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
|
||||
}
|
||||
val compactNotification = boolean("compact_notification", false)
|
||||
val showProgressBar = boolean("show_progress_bar", true)
|
||||
val showLifetimeStats = boolean("show_lifetime_stats", false)
|
||||
val showQueuePreview = boolean("show_queue_preview", true)
|
||||
val thermalProtection = boolean("thermal_protection", false)
|
||||
|
||||
// Resource Intelligence: Smart triggers for battery and data safety
|
||||
val onlyOnWifi = boolean("only_on_wifi", false)
|
||||
val preFetchSnaps = boolean("pre_fetch_snaps", false)
|
||||
val pauseDuringGaming = boolean("pause_during_gaming", false)
|
||||
val safeProcessing = boolean("safe_processing", true)
|
||||
val onlyWhenIdle = boolean("only_when_idle", false)
|
||||
|
||||
@@ -47,6 +47,14 @@ class DatabaseAccess(
|
||||
} == true
|
||||
}
|
||||
|
||||
private val hasArroyoUserConversationTable by lazy {
|
||||
useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_conversation'")?.use { query ->
|
||||
query.moveToFirst() && query.getStringOrNull("name") == "user_conversation"
|
||||
}
|
||||
} == true
|
||||
}
|
||||
|
||||
private fun useDatabase(database: DatabaseType, writeMode: Boolean = false): SQLiteDatabase? {
|
||||
// only cache read-only databases
|
||||
if (!writeMode && openedDatabases.containsKey(database) && openedDatabases[database]?.isOpen == true) {
|
||||
@@ -148,6 +156,10 @@ class DatabaseAccess(
|
||||
}?.toMutableMap() ?: mutableMapOf()
|
||||
}
|
||||
|
||||
if (!hasArroyoUserConversationTable) {
|
||||
return@lazy mutableMapOf()
|
||||
}
|
||||
|
||||
(useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery(
|
||||
"SELECT client_conversation_id, conversation_type, user_id FROM user_conversation WHERE user_id != ?",
|
||||
@@ -354,7 +366,7 @@ class DatabaseAccess(
|
||||
}
|
||||
|
||||
fun getConversationType(conversationId: String): Int? {
|
||||
if (hasArroyoConversationTable) {
|
||||
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
|
||||
return getFeedEntryByConversationId(conversationId)?.conversationType
|
||||
}
|
||||
|
||||
@@ -372,7 +384,9 @@ class DatabaseAccess(
|
||||
}
|
||||
|
||||
fun getDMConversationId(userId: String): String? {
|
||||
if (hasArroyoConversationTable) {
|
||||
friendDMsCache[userId]?.let { return it }
|
||||
|
||||
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
|
||||
return friendDMsCache[userId]
|
||||
}
|
||||
|
||||
@@ -408,6 +422,10 @@ class DatabaseAccess(
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasArroyoUserConversationTable) {
|
||||
return getFeedEntryByConversationId(conversationId)?.participants
|
||||
}
|
||||
|
||||
return useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery(
|
||||
"SELECT user_id FROM user_conversation WHERE client_conversation_id = ?",
|
||||
|
||||
@@ -99,6 +99,7 @@ class FeatureManager(
|
||||
MeoPasscodeBypass(),
|
||||
AppLock(),
|
||||
CameraTweaks(),
|
||||
PerformanceMode(),
|
||||
InfiniteStoryBoost(),
|
||||
PinConversations(),
|
||||
DeviceSpooferHook(),
|
||||
|
||||
@@ -2,9 +2,11 @@ package me.eternal.purrfectsnap.core.features
|
||||
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType) : Feature(name) {
|
||||
private val listeners = mutableListOf<(String, Boolean) -> Unit>()
|
||||
private val ruleCache = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
fun addStateListener(listener: (conversationId: String, newState: Boolean) -> Unit) {
|
||||
listeners.add(listener)
|
||||
@@ -13,18 +15,22 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
|
||||
open fun getRuleState() = context.config.rules.getRuleState(ruleType)
|
||||
|
||||
fun setState(conversationId: String, state: Boolean) {
|
||||
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
context.bridgeClient.setRule(
|
||||
context.database.getDMOtherParticipant(conversationId) ?: conversationId,
|
||||
targetId,
|
||||
ruleType,
|
||||
state
|
||||
)
|
||||
ruleCache[targetId] = state
|
||||
listeners.forEach { it(conversationId, state) }
|
||||
}
|
||||
|
||||
fun getState(conversationId: String) =
|
||||
context.bridgeClient.getRules(
|
||||
context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
).contains(ruleType) && getRuleState() != null
|
||||
fun getState(conversationId: String): Boolean {
|
||||
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
return ruleCache.getOrPut(targetId) {
|
||||
context.bridgeClient.getRules(targetId).contains(ruleType)
|
||||
} && getRuleState() != null
|
||||
}
|
||||
|
||||
fun canUseRule(conversationId: String): Boolean {
|
||||
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
|
||||
|
||||
@@ -37,18 +37,39 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}.getOrNull()
|
||||
|
||||
val propertyOverrides = mutableMapOf<String, ConfigFilter>()
|
||||
val loggedOverrides = mutableSetOf<String>()
|
||||
|
||||
fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: (ConfigKeyInfo) -> Any?, isAppExperiment: Boolean = false) {
|
||||
propertyOverrides[key] = ConfigFilter(filter, value, isAppExperiment)
|
||||
}
|
||||
|
||||
fun logPerformanceOverride(key: String, value: Any?) {
|
||||
if (!key.contains("PRELOAD") &&
|
||||
!key.contains("PERFORMANCE") &&
|
||||
!key.contains("WARM") &&
|
||||
!key.contains("PREFETCH") &&
|
||||
!key.contains("LATENCY") &&
|
||||
!key.contains("ANALYTICS") &&
|
||||
!key.contains("THREAD_PRIORITY") &&
|
||||
!key.contains("HD_MODE") &&
|
||||
!key.contains("LENS") &&
|
||||
!key.contains("THUMBNAIL")
|
||||
) {
|
||||
return
|
||||
}
|
||||
synchronized(loggedOverrides) {
|
||||
if (!loggedOverrides.add(key)) return
|
||||
}
|
||||
context.log.info("Performance override applied: $key=$value", "PerformanceMode")
|
||||
}
|
||||
|
||||
overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() },
|
||||
{ true })
|
||||
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() },
|
||||
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null },
|
||||
{ true }, isAppExperiment = true)
|
||||
|
||||
run {
|
||||
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() }
|
||||
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
|
||||
val level7Value = { _: ConfigKeyInfo -> 700 }
|
||||
arrayOf(
|
||||
"MY_STORY_UPLOAD_QUALITY_LEVEL",
|
||||
@@ -72,10 +93,14 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
isForceQuality, { true })
|
||||
overrideProperty("MEDIA_QUALITY_LEVEL_DOWNGRADING_PERCENTAGE",
|
||||
isForceQuality, { 0.0f })
|
||||
overrideProperty("CHAT_MEDIA_IMPORT_TRANSCODED_QUALITY",
|
||||
isForceQuality, { 4 })
|
||||
overrideProperty("POSTED_STORY_IMPORT_TRANSCODED_QUALITY",
|
||||
isForceQuality, { 4 })
|
||||
}
|
||||
|
||||
run {
|
||||
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() }
|
||||
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
|
||||
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY", isDisableCompression, { 100 })
|
||||
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY_V2", isDisableCompression, { 100 })
|
||||
}
|
||||
@@ -84,6 +109,44 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
{ true })
|
||||
overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() },
|
||||
{ true })
|
||||
overrideProperty("PREVIEW_PRELOAD_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ true })
|
||||
overrideProperty("BUFFERED_VIDEO_RECORDING_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ true })
|
||||
overrideProperty("CAMERA_THREAD_PRIORITY", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ true })
|
||||
overrideProperty("HD_MODE_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() == "max" },
|
||||
{ true })
|
||||
|
||||
arrayOf(
|
||||
"FEATURE_PRELOADER",
|
||||
"USER_STORY_PRELOAD",
|
||||
"STARTUP_LENS_ACTIVATOR",
|
||||
"LENSES_PREVIEW_ACTIVATOR",
|
||||
"THUMBNAIL_PRESENTER_ACTIVATOR",
|
||||
"SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR",
|
||||
"SERVER_PREFETCH",
|
||||
"SERVER_PREFETCH_WITH_COF",
|
||||
"DISCOVER_FEED_PERFORMANCE",
|
||||
"DISCOVER_FEED_STORY_PREFETCH",
|
||||
"DISCOVER_FEED_THUMBNAILS",
|
||||
"LOGIN_PRELOAD",
|
||||
"PREFETCH_REPO_SUBSCRIBE_ON_CPU",
|
||||
"COMPUTE_FEED_CACHE_WITH_TTL",
|
||||
"COMPUTE_FEED_NETWORK_WITH_CACHE",
|
||||
"OPERA_WARMUP",
|
||||
"REFACTORED_WITH_WARMUP_LENS",
|
||||
"SHOW_PREFETCH",
|
||||
).forEach { key ->
|
||||
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() != null }, { true })
|
||||
}
|
||||
|
||||
overrideProperty("LOAD_LATENCY_TRACKER_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ false })
|
||||
overrideProperty("ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ false })
|
||||
overrideProperty("LOCK_SCREEN_ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ false })
|
||||
overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() },
|
||||
{ true })
|
||||
|
||||
@@ -115,7 +178,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
|
||||
propertyOverrides[propertyKey.name]?.let { (filter, value) ->
|
||||
if (!filter(propertyKey)) return@let
|
||||
param.setResult(value(propertyKey))
|
||||
value(propertyKey).also {
|
||||
logPerformanceOverride(propertyKey.name ?: return@also, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +201,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
propertyOverrides[key]?.let { (filter, value) ->
|
||||
val keyInfo = getConfigKeyInfo(enumData) ?: return@let
|
||||
if (!filter(keyInfo)) return@let
|
||||
setValue(value(keyInfo))
|
||||
value(keyInfo).also {
|
||||
logPerformanceOverride(key, it)
|
||||
setValue(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +220,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}
|
||||
propertyOverrides[keyInfo.name]?.let { (filter, value, isAppExperiment) ->
|
||||
if (isAppExperiment != true || !filter(keyInfo)) return@let
|
||||
param.setResult(value(keyInfo))
|
||||
value(keyInfo).also {
|
||||
logPerformanceOverride(keyInfo.name ?: return@also, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +246,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}
|
||||
|
||||
val propertyOverride = propertyOverrides[keyInfo.name] ?: return@hook
|
||||
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let { param.setResult(it) }
|
||||
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let {
|
||||
logPerformanceOverride(keyInfo.name ?: return@let, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -9,39 +10,43 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.app.ActivityManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.edit
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import me.eternal.purrfectsnap.bridge.AutoOpenInterface
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
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.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.Calendar
|
||||
import kotlin.random.Random
|
||||
import me.eternal.purrfectsnap.bridge.AutoOpenInterface
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.random.Random
|
||||
|
||||
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"
|
||||
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_TOTAL_DETECTED = "auto_open_total_detected"
|
||||
private const val PREF_SESSION_START = "auto_open_session_start"
|
||||
@@ -62,6 +67,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
private val snapQueue = MutableSharedFlow<Long>(extraBufferCapacity = 100)
|
||||
private val openedSnaps = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val queuedSnaps = mutableListOf<SnapQueueItem>()
|
||||
private val deadLetterQueue = mutableListOf<SnapQueueItem>()
|
||||
|
||||
private val nameCache = ConcurrentHashMap<String, String>()
|
||||
private val conversationTypeCache = ConcurrentHashMap<String, String>()
|
||||
@@ -76,6 +82,28 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
private var currentSpeedText = "Full Speed"
|
||||
private var isCurrentlyWaiting = false
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var lastQueueActivity = System.currentTimeMillis()
|
||||
|
||||
// Throttling & Performance fields
|
||||
private val lastNotificationUpdate = AtomicLong(0)
|
||||
private val notificationUpdateDelay = 1000L
|
||||
private val pendingNotificationUpdate = AtomicBoolean(false)
|
||||
private val processedSinceLastSave = AtomicInteger(0)
|
||||
private val snapTimestamps = LinkedList<Long>()
|
||||
|
||||
// Safety & Synergy
|
||||
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)
|
||||
}.onFailure {
|
||||
context.log.warn("Failed to cancel Auto Open Snaps notification: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
data class SnapQueueItem(
|
||||
val conversationId: String,
|
||||
@@ -84,7 +112,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
var senderName: String = "Pending...",
|
||||
var conversationType: String = "Processing",
|
||||
val contentType: String,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
var retryCount: Int = 0
|
||||
)
|
||||
|
||||
private val autoOpenInterface = object : AutoOpenInterface.Stub() {
|
||||
@@ -104,6 +133,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
lastPausedAt.set(0)
|
||||
sessionStartTime.set(System.currentTimeMillis())
|
||||
synchronized(queuedSnaps) { queuedSnaps.clear() }
|
||||
synchronized(deadLetterQueue) { deadLetterQueue.clear() }
|
||||
openedSnaps.clear()
|
||||
|
||||
prefs.edit()
|
||||
@@ -127,32 +157,47 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
if (paused) {
|
||||
lastPausedAt.set(System.currentTimeMillis())
|
||||
} else {
|
||||
val pauseStarted = lastPausedAt.get()
|
||||
if (pauseStarted > 0) {
|
||||
totalPausedDuration.addAndGet(System.currentTimeMillis() - pauseStarted)
|
||||
lastPausedAt.set(0)
|
||||
if (lastPausedAt.get() > 0) {
|
||||
totalPausedDuration.addAndGet(System.currentTimeMillis() - lastPausedAt.get())
|
||||
}
|
||||
snapQueue.tryEmit(System.currentTimeMillis())
|
||||
}
|
||||
updateStatusNotification()
|
||||
}
|
||||
ACTION_CLEAR_QUEUE -> {
|
||||
clearInternalState()
|
||||
this@AutoOpenSnaps.context.log.info("[AutoOpen] All statistics and queue reset.")
|
||||
synchronized(queuedSnaps) {
|
||||
queuedSnaps.clear()
|
||||
}
|
||||
synchronized(deadLetterQueue) {
|
||||
deadLetterQueue.clear()
|
||||
}
|
||||
|
||||
// Reset session and persistent counters to zero
|
||||
totalProcessed.set(0)
|
||||
sessionProcessed.set(0)
|
||||
totalDetected.set(0)
|
||||
|
||||
triggerLazySave()
|
||||
updateStatusNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (config.globalState != true) return
|
||||
context.log.info("[AutoOpen] Initializing Ultra Premium engine...")
|
||||
|
||||
val messaging = context.feature(Messaging::class)
|
||||
restorePersistence()
|
||||
hasBeenActive.set(true)
|
||||
|
||||
// Verify configuration state before marking as active to prevent background process notification spam
|
||||
if (config.globalState == true) {
|
||||
hasBeenActive.set(true)
|
||||
} else {
|
||||
// Feature is disabled; silent exit to avoid process-wide 'Deactivated' notices
|
||||
return
|
||||
}
|
||||
|
||||
if (config.allowRunningInBackground.get()) {
|
||||
acquireWakeLock()
|
||||
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
|
||||
hook("appStateChanged", HookStage.BEFORE) { param ->
|
||||
if (config.allowRunningInBackground.get()) {
|
||||
@@ -163,8 +208,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
}
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
methods.first { it.name == "appStateChanged" }.let { method ->
|
||||
method.invoke(param.thisObject(), method.parameterTypes[0].enumConstants!!.first { it.toString() == "ACTIVE" })
|
||||
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<Any>(), activeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,11 +233,35 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_PAUSE_RESUME)
|
||||
addAction(ACTION_CLEAR_QUEUE)
|
||||
addAction(Intent.ACTION_BATTERY_CHANGED)
|
||||
}
|
||||
|
||||
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()
|
||||
context.log.warn("[THERMAL] Device hit ${temp}C. Throttling AutoOpen.")
|
||||
} else if (isThermalThrottled && temp <= 36f && System.currentTimeMillis() - lastThermalThrottleAt > 600000) {
|
||||
isThermalThrottled = false
|
||||
context.log.info("[THERMAL] Device cooled to ${temp}C. Resuming full speed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
context.coroutineScope.launch(Dispatchers.Default) {
|
||||
@@ -198,9 +273,31 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
if (remainingCount == 0 && sessionProcessed.get() > 0) {
|
||||
sessionProcessed.set(0)
|
||||
triggerLazySave()
|
||||
}
|
||||
|
||||
if (remainingCount > 0) {
|
||||
lastQueueActivity = System.currentTimeMillis()
|
||||
acquireWakeLock()
|
||||
if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
} else {
|
||||
// IDLE REVIVAL: Check dead letter queue every 5 mins when idle
|
||||
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 > 600000) { // 10 mins true idle
|
||||
releaseWakeLock()
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusNotification()
|
||||
if (remainingCount > 0) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
@@ -208,15 +305,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
context.coroutineScope.launch(Dispatchers.Default, CoroutineStart.UNDISPATCHED) {
|
||||
snapQueue.collect { _ ->
|
||||
while (isActive && config.globalState == true) {
|
||||
val item = synchronized(queuedSnaps) { queuedSnaps.firstOrNull() } ?: break
|
||||
|
||||
while (isPaused.get() || config.globalState != true) {
|
||||
if (config.globalState != true) return@collect
|
||||
currentStatusText = context.translation["auto_open_snaps.status_paused"] ?: "Paused"
|
||||
isCurrentlyWaiting = true
|
||||
updateStatusNotification()
|
||||
delay(2000)
|
||||
if (isPaused.get()) {
|
||||
delay(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
val item = synchronized(queuedSnaps) {
|
||||
if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null
|
||||
} ?: break
|
||||
|
||||
var resourceWaiting = true
|
||||
while (resourceWaiting) {
|
||||
@@ -229,56 +325,57 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
when {
|
||||
config.onlyOnWifi.get() && !isWifi -> {
|
||||
currentStatusText = context.translation["auto_open_snaps.only_on_wifi.name"] ?: "Waiting for WiFi..."
|
||||
currentSpeedText = context.translation["auto_open_snaps.paused_status"] ?: "Paused (No WiFi)"
|
||||
currentSpeedText = "Throttled"
|
||||
isCurrentlyWaiting = true
|
||||
delay(5000)
|
||||
}
|
||||
config.onlyWhenIdle.get() && !isIdle && !inSleepWindow -> {
|
||||
currentStatusText = context.translation["auto_open_snaps.only_when_idle.name"] ?: "Waiting for idle..."
|
||||
currentSpeedText = context.translation["auto_open_snaps.paused_status"] ?: "Paused (Device Active)"
|
||||
currentSpeedText = "Throttled"
|
||||
isCurrentlyWaiting = true
|
||||
delay(5000)
|
||||
}
|
||||
config.pauseDuringGaming.get() && isGaming -> {
|
||||
currentStatusText = context.translation["auto_open_snaps.pause_during_gaming.name"] ?: "Paused (Gaming Mode)"
|
||||
currentSpeedText = context.translation["auto_open_snaps.paused_status"] ?: "Paused (Gaming)"
|
||||
currentSpeedText = "Paused"
|
||||
isCurrentlyWaiting = true
|
||||
delay(60000)
|
||||
}
|
||||
else -> {
|
||||
resourceWaiting = false
|
||||
currentSpeedText = if (inSleepWindow) context.translation["auto_open_snaps.speed_throttled"] ?: "Throttled" else context.translation["auto_open_snaps.processing_speed_full"] ?: "Full Speed"
|
||||
currentSpeedText = if (inSleepWindow || isThermalThrottled) "Throttled" else "Full Speed"
|
||||
}
|
||||
}
|
||||
if (resourceWaiting) updateStatusNotification()
|
||||
}
|
||||
|
||||
if (isPaused.get() || config.globalState != true) continue
|
||||
if (isPaused.get() || config.globalState != true) {
|
||||
synchronized(queuedSnaps) { queuedSnaps.add(0, item) }
|
||||
continue
|
||||
}
|
||||
isCurrentlyWaiting = false
|
||||
|
||||
val inSleepWindow = if (config.onlyWhenIdle.get()) isInsideSleepWindow() else false
|
||||
if (inSleepWindow) {
|
||||
currentStatusText = context.translation["auto_open_snaps.speed_throttled"] ?: "Throttled"
|
||||
if (inSleepWindow || isThermalThrottled) {
|
||||
currentStatusText = if (isThermalThrottled) context.translation["auto_open_snaps.thermal_status_title"] ?: "Thermal Cooling" else context.translation["auto_open_snaps.speed_throttled"] ?: "Throttled"
|
||||
delay(Random.nextLong(3000, 5000))
|
||||
} else if (lastConversationId != null && lastConversationId != item.conversationId) {
|
||||
currentStatusText = "..."
|
||||
currentStatusText = "Switching chats..."
|
||||
delay(Random.nextLong(1500, 2500))
|
||||
batchSnapCount = 0
|
||||
triggerLazySave()
|
||||
} else if (lastConversationId == item.conversationId) {
|
||||
when {
|
||||
isThermalThrottled -> delay(Random.nextLong(100, 150))
|
||||
config.safeProcessing.get() -> delay(Random.nextLong(50, 150))
|
||||
else -> delay(Random.nextLong(10, 40))
|
||||
}
|
||||
}
|
||||
|
||||
lastConversationId = item.conversationId
|
||||
currentStatusText = if (inSleepWindow) context.translation["auto_open_snaps.status_active"] ?: "Active" else context.translation["auto_open_snaps.status_active"] ?: "Opening snap..."
|
||||
currentStatusText = context.translation["auto_open_snaps.status_active"] ?: "Opening snap..."
|
||||
updateStatusNotification()
|
||||
|
||||
if (config.safeProcessing.get() && !inSleepWindow) {
|
||||
batchSnapCount++
|
||||
if (batchSnapCount % 10 == 0) {
|
||||
currentStatusText = "..."
|
||||
updateStatusNotification()
|
||||
delay(Random.nextLong(3000, 5000))
|
||||
}
|
||||
}
|
||||
|
||||
var success = false
|
||||
val startTime = System.currentTimeMillis()
|
||||
var currentRetryDelay = config.retryDelay.get().toLong()
|
||||
@@ -287,7 +384,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
if (isPaused.get() || config.globalState != true) break
|
||||
while ((!config.allowRunningInBackground.get() && context.isMainActivityPaused) || (messaging.conversationManager == null && !config.allowRunningInBackground.get())) {
|
||||
if (config.globalState != true || isPaused.get()) break
|
||||
currentStatusText = "..."
|
||||
currentStatusText = "Waiting for UI..."
|
||||
isCurrentlyWaiting = true
|
||||
updateStatusNotification()
|
||||
delay(2000)
|
||||
@@ -299,9 +396,19 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
if (success) {
|
||||
totalProcessed.incrementAndGet()
|
||||
sessionProcessed.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())
|
||||
saveStatsToDisk()
|
||||
|
||||
val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
val threshold = if (remaining > 100) 100 else 25
|
||||
if (processedSinceLastSave.incrementAndGet() >= threshold) {
|
||||
triggerLazySave()
|
||||
processedSinceLastSave.set(0)
|
||||
}
|
||||
break
|
||||
}
|
||||
if (i < config.retryAttempts.get() - 1) {
|
||||
@@ -311,20 +418,26 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
synchronized(queuedSnaps) { queuedSnaps.removeAll { it.messageId == item.messageId }; saveQueueToDisk() }
|
||||
} else if (!isPaused.get()) {
|
||||
if (!success && !isPaused.get()) {
|
||||
currentStatusText = context.translation["auto_open_snaps.status_failed"]?.replace("{sender}", item.senderName) ?: "Failed to open"
|
||||
updateStatusNotification()
|
||||
delay(5000)
|
||||
synchronized(queuedSnaps) { queuedSnaps.removeAll { it.messageId == item.messageId }; saveQueueToDisk() }
|
||||
|
||||
// MOVE TO DEAD LETTER QUEUE (Revival Engine)
|
||||
synchronized(deadLetterQueue) {
|
||||
if (deadLetterQueue.size < 100) deadLetterQueue.add(item)
|
||||
else { deadLetterQueue.removeAt(0); deadLetterQueue.add(item) }
|
||||
}
|
||||
delay(2000)
|
||||
}
|
||||
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
|
||||
currentStatusText = context.translation["auto_open_snaps.status_monitoring"] ?: "Monitoring..."
|
||||
isCurrentlyWaiting = false
|
||||
updateStatusNotification()
|
||||
releaseWakeLock()
|
||||
delay(500)
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
|
||||
currentStatusText = context.translation["auto_open_snaps.status_monitoring"] ?: "Monitoring..."
|
||||
isCurrentlyWaiting = false
|
||||
triggerLazySave()
|
||||
updateStatusNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,29 +445,83 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
|
||||
context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
|
||||
if (config.globalState != true) return@subscribe
|
||||
if (event.message.senderId?.toString() == context.database.myUserId) return@subscribe
|
||||
val conversationId = event.message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
|
||||
val clientMessageId = event.message.messageDescriptor?.messageId ?: return@subscribe
|
||||
val contentType = event.message.messageContent?.contentType
|
||||
|
||||
val message = event.message
|
||||
// Stability: Only process committed messages to avoid ghost events during sending/failure
|
||||
if (message.messageState != me.eternal.purrfectsnap.common.data.MessageState.COMMITTED) return@subscribe
|
||||
if (message.senderId?.toString() == context.database.myUserId) return@subscribe
|
||||
|
||||
val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
|
||||
val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe
|
||||
val contentType = message.messageContent?.contentType
|
||||
|
||||
// Validation: Only process viewable snaps and external media
|
||||
if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe
|
||||
if (event.message.messageMetadata?.openedBy?.any { it.toString() == context.database.myUserId } == true) return@subscribe
|
||||
if (contentType == ContentType.SNAP_NOT_VIEWABLE) return@subscribe
|
||||
|
||||
if (message.messageMetadata?.openedBy?.any { it.toString() == context.database.myUserId } == true) return@subscribe
|
||||
|
||||
acquireWakeLock()
|
||||
|
||||
context.coroutineScope.launch(Dispatchers.Default) {
|
||||
if (!canUseRule(conversationId)) return@launch
|
||||
if (!openedSnaps.add(clientMessageId)) return@launch
|
||||
if (openedSnaps.size > 15000) openedSnaps.clear()
|
||||
val senderId = event.message.senderId?.toString() ?: "unknown"
|
||||
val item = SnapQueueItem(conversationId, clientMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
|
||||
synchronized(queuedSnaps) {
|
||||
if (queuedSnaps.size >= config.queueSize.get()) queuedSnaps.removeFirstOrNull()
|
||||
queuedSnaps.add(item); totalDetected.incrementAndGet(); saveQueueToDisk()
|
||||
|
||||
synchronized(openedSnaps) {
|
||||
if (openedSnaps.contains(clientMessageId)) return@launch
|
||||
openedSnaps.add(clientMessageId)
|
||||
// Periodic cache maintenance to ensure O(1) performance
|
||||
if (openedSnaps.size > 5000) openedSnaps.clear()
|
||||
}
|
||||
acquireWakeLock()
|
||||
snapQueue.tryEmit(System.currentTimeMillis())
|
||||
|
||||
val senderId = message.senderId?.toString() ?: "unknown"
|
||||
val item = SnapQueueItem(conversationId, clientMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
|
||||
|
||||
val currentQueueSize = synchronized(queuedSnaps) {
|
||||
if (queuedSnaps.size >= config.queueSize.get()) queuedSnaps.removeFirstOrNull()
|
||||
queuedSnaps.add(item)
|
||||
queuedSnaps.size
|
||||
}
|
||||
totalDetected.incrementAndGet()
|
||||
|
||||
// Smart Pre-fetch Engine: Early media loading into internal cache
|
||||
if (config.preFetchSnaps.get()) {
|
||||
val isWifi = isWifiConnected()
|
||||
val mobileLimit = 250
|
||||
|
||||
// Connection-Aware Limit: 1000 for WiFi, 250 for Mobile
|
||||
val canFetch = if (isWifi) currentQueueSize <= 1000 else currentQueueSize <= mobileLimit
|
||||
|
||||
// Dynamic RAM Window (20/50/100) to prevent UI jitter on low-end devices
|
||||
if (canFetch && currentQueueSize <= getFetchWindowSize()) {
|
||||
runCatching {
|
||||
// Stable feature access via explicit KClass resolution
|
||||
context.feature(Messaging::class).conversationManager?.fetchMessage(conversationId, clientMessageId, {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPaused.get()) {
|
||||
snapQueue.tryEmit(System.currentTimeMillis())
|
||||
}
|
||||
|
||||
updateStatusNotification()
|
||||
triggerLazySave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFetchWindowSize(): Int {
|
||||
val am = context.androidContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val memInfo = ActivityManager.MemoryInfo()
|
||||
am.getMemoryInfo(memInfo)
|
||||
val totalRamGb = memInfo.totalMem / (1024 * 1024 * 1024)
|
||||
return when {
|
||||
totalRamGb <= 2 -> 20
|
||||
totalRamGb <= 4 -> 50
|
||||
else -> 100
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performOpen(messaging: Messaging, item: SnapQueueItem): Boolean = withContext(Dispatchers.IO) {
|
||||
val manager = messaging.conversationManager ?: return@withContext false
|
||||
withTimeoutOrNull(5000) {
|
||||
@@ -373,84 +540,114 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
return when { h > 0 -> "${h}h ${m}m ${s}s"; m > 0 -> "${m}m ${s}s"; else -> "${s}s" }
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatusNotification() {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val lastUpdate = lastNotificationUpdate.get()
|
||||
|
||||
if ((currentTime - lastUpdate) < notificationUpdateDelay) {
|
||||
if (pendingNotificationUpdate.compareAndSet(false, true)) {
|
||||
context.coroutineScope.launch {
|
||||
delay(notificationUpdateDelay - (currentTime - lastUpdate))
|
||||
pendingNotificationUpdate.set(false)
|
||||
updateStatusNotificationInternal()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastNotificationUpdate.set(currentTime)
|
||||
updateStatusNotificationInternal()
|
||||
}
|
||||
|
||||
// Notification state cache to prevent redundant UI updates and save battery
|
||||
private var lastNotificationState: String? = null
|
||||
|
||||
private fun updateStatusNotificationInternal() {
|
||||
val processed = sessionProcessed.get()
|
||||
val total = totalProcessed.get()
|
||||
val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
|
||||
// Generate a state fingerprint to check if a notification update is actually necessary
|
||||
val currentStateFingerprint = "$processed|$total|$remaining|$currentStatusText|$isPaused"
|
||||
if (currentStateFingerprint == lastNotificationState && remaining == 0) return
|
||||
lastNotificationState = currentStateFingerprint
|
||||
|
||||
if (total <= 0 && remaining <= 0 && processed <= 0) return
|
||||
|
||||
val isWorking = remaining > 0
|
||||
val showProgressBar = config.showProgressBar.get() == true
|
||||
val isCompact = config.compactNotification.get() == true
|
||||
val sessionTotal = processed + remaining
|
||||
val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0
|
||||
val speed = getSnapsPerSecond()
|
||||
|
||||
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).setAutoCancel(!isWorking).setOnlyAlertOnce(true)
|
||||
|
||||
// Disable native progress bar to avoid "Double Bar" issue with our Premium Unicode bar
|
||||
builder.setProgress(0, 0, false)
|
||||
.setGroup(NOTIFICATION_GROUP_KEY).setGroupSummary(false)
|
||||
|
||||
val eta = if (isWorking && !isCurrentlyWaiting && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else null
|
||||
|
||||
if (config.compactNotification.get() == true) {
|
||||
builder.setContentTitle("Auto-Open: $currentStatusText")
|
||||
builder.setContentText("Remaining: $remaining | Opened: $processed" + (eta?.let { " | ETA: $it" } ?: ""))
|
||||
builder.setContentTitle("Auto-Open: $currentStatusText")
|
||||
|
||||
if (isWorking) {
|
||||
builder.setContentText("Opened: $processed │ ETA: ${eta ?: "..."}")
|
||||
builder.setSubText("$progressPercent% • $remaining Queued")
|
||||
// Show progress bar only when actively processing snaps
|
||||
builder.setProgress(sessionTotal, processed, false)
|
||||
} else {
|
||||
builder.setContentTitle("Auto-Open: $currentStatusText")
|
||||
builder.setContentText("Remaining: $remaining | Opened: $processed")
|
||||
// Static status for monitoring stage to save battery
|
||||
builder.setContentText("$processed Opened Today │ $total Lifetime")
|
||||
// Remove subtext entirely when idle to prevent redundancy with the title
|
||||
builder.setSubText(null)
|
||||
// Remove progress bar entirely during idle/monitoring stage to stop animation CPU drain
|
||||
builder.setProgress(0, 0, false)
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
if (config.compactNotification.get() != true) {
|
||||
if (!isCompact) {
|
||||
val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) }
|
||||
val bigTextStyle = Notification.BigTextStyle()
|
||||
|
||||
// Set summary text to empty to force the header to stay clean in expanded view
|
||||
bigTextStyle.setSummaryText("")
|
||||
|
||||
val detailText = buildString {
|
||||
if (showProgressBar) append("${context.translation["auto_open_snaps.notification_statistics"] ?: "STATISTICS"} - ${drawProgressBar(progressPercent)}\n")
|
||||
else append("${context.translation["auto_open_snaps.notification_statistics"] ?: "STATISTICS"}\n")
|
||||
|
||||
append("\u251c\u2500 ${context.translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processed snaps\n")
|
||||
append("\u251c\u2500 ${context.translation["auto_open_snaps.queue_size"] ?: "Remaining"}: $remaining snaps\n")
|
||||
|
||||
if (eta != null) {
|
||||
append("\u251c\u2500 ${context.translation["auto_open_snaps.estimated_time"] ?: "Estimated time"}: $eta\n")
|
||||
append("QUEUE STATISTICS\n")
|
||||
append("├─ Opened: $processed snaps\n")
|
||||
append("├─ Remaining: $remaining snaps\n")
|
||||
if (eta != null) append("├─ Estimated time: $eta\n")
|
||||
append("├─ Lifetime Opened: $total snaps\n")
|
||||
append("└─ Speed: $currentSpeedText (${String.format("%.1f", speed)}/s)\n\n")
|
||||
|
||||
append("QUEUE PREVIEW\n")
|
||||
if (isWorking) {
|
||||
recentSnaps.reversed().forEach { item ->
|
||||
append("• ${item.senderName} │ ${item.conversationType} (${item.contentType})\n")
|
||||
}
|
||||
} else {
|
||||
append(context.translation["auto_open_snaps.notification_no_snaps_queue"] ?: "Monitoring snaps in background...")
|
||||
}
|
||||
|
||||
if (config.showLifetimeStats.get()) {
|
||||
append("\u251c\u2500 ${context.translation["auto_open_snaps.notification_total_opened"] ?: "Lifetime Opened"}: $total snaps\n")
|
||||
}
|
||||
|
||||
if (config.showQueuePreview.get()) {
|
||||
append("\u2514\u2500 ${context.translation["auto_open_snaps.processing_speed"] ?: "Speed"}: $currentSpeedText\n\n${context.translation["auto_open_snaps.notification_queue_preview"] ?: "QUEUE PREVIEW"}\n")
|
||||
if (isWorking) {
|
||||
recentSnaps.reversed().forEach { item ->
|
||||
append("\u2022 ${item.senderName}")
|
||||
if (item.conversationType != "Friend DM" && item.conversationType != "Processing") append(" \u2502 ${item.conversationType}")
|
||||
append(" (${item.contentType})\n")
|
||||
}
|
||||
} else append(context.translation["auto_open_snaps.notification_no_snaps_queue"] ?: "Monitoring snaps in background...")
|
||||
} else append("\u2514\u2500 ${context.translation["auto_open_snaps.processing_speed"] ?: "Speed"}: $currentSpeedText")
|
||||
}
|
||||
bigTextStyle.bigText(detailText); builder.setStyle(bigTextStyle)
|
||||
bigTextStyle.bigText(detailText)
|
||||
builder.setStyle(bigTextStyle)
|
||||
}
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun drawProgressBar(percent: Int): String {
|
||||
val totalBlocks = 12; val filledBlocks = (percent * totalBlocks) / 100
|
||||
return buildString {
|
||||
append("[")
|
||||
repeat(totalBlocks) { i ->
|
||||
when { i < filledBlocks -> append("\u2501"); i == filledBlocks -> append("\u2B26"); else -> append("\u2500") }
|
||||
}
|
||||
append("] $percent%")
|
||||
}
|
||||
}
|
||||
|
||||
private fun shutdownFeature() {
|
||||
notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||
cancelStatusNotification()
|
||||
val finalCount = totalProcessed.get()
|
||||
if (hasBeenActive.get()) {
|
||||
val elapsedMillis = System.currentTimeMillis() - sessionStartTime.get() - totalPausedDuration.get()
|
||||
@@ -459,19 +656,39 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Auto-Open: Deactivated")
|
||||
.setContentText("Opened: $finalCount snaps | Session: ${durationMins}m")
|
||||
.setGroup(NOTIFICATION_GROUP_KEY)
|
||||
.setAutoCancel(true).build()
|
||||
notificationManager.notify(Random.nextInt(), summary)
|
||||
// Use static ID to overwrite previous deactivate notice and prevent icon stacking
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID + 1, summary)
|
||||
hasBeenActive.set(false)
|
||||
}
|
||||
resetPersistence(); releaseWakeLock()
|
||||
triggerLazySave()
|
||||
releaseWakeLock()
|
||||
}
|
||||
|
||||
private fun saveStatsToDisk() {
|
||||
prefs.edit().putInt(PREF_TOTAL_OPENED, totalProcessed.get()).putInt(PREF_TOTAL_DETECTED, totalDetected.get()).putLong(PREF_SESSION_START, sessionStartTime.get()).apply()
|
||||
private fun triggerLazySave() {
|
||||
needsSaving.set(true)
|
||||
if (isSaving.compareAndSet(false, true)) {
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
while (needsSaving.get()) {
|
||||
needsSaving.set(false)
|
||||
saveToDiskInternal()
|
||||
delay(1000)
|
||||
}
|
||||
isSaving.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveQueueToDisk() {
|
||||
synchronized(queuedSnaps) { prefs.edit().putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps)).apply() }
|
||||
private fun saveToDiskInternal() {
|
||||
prefs.edit {
|
||||
putInt(PREF_TOTAL_OPENED, totalProcessed.get())
|
||||
putInt(PREF_TOTAL_DETECTED, totalDetected.get())
|
||||
putLong(PREF_SESSION_START, sessionStartTime.get())
|
||||
synchronized(queuedSnaps) {
|
||||
putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun restorePersistence() {
|
||||
@@ -483,8 +700,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
try {
|
||||
val type = object : TypeToken<List<SnapQueueItem>>() {}.type
|
||||
val restored: List<SnapQueueItem> = gson.fromJson(savedQueueJson, type)
|
||||
synchronized(queuedSnaps) { queuedSnaps.clear(); queuedSnaps.addAll(restored) }
|
||||
} catch (e: Exception) { resetPersistence() }
|
||||
val now = System.currentTimeMillis()
|
||||
synchronized(queuedSnaps) {
|
||||
queuedSnaps.clear()
|
||||
queuedSnaps.addAll(restored.filter { (now - it.timestamp) < 3600000 })
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
prefs.edit().remove(PREF_SAVED_QUEUE).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,30 +720,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
try {
|
||||
val sleepWindow = config.sleepWindow.get()
|
||||
if (!sleepWindow.contains("-") || !sleepWindow.contains(":")) return false
|
||||
|
||||
val window = sleepWindow.split("-")
|
||||
if (window.size != 2) return false
|
||||
|
||||
val startStr = window[0].split(":")
|
||||
val endStr = window[1].split(":")
|
||||
if (startStr.size != 2 || endStr.size != 2) return false
|
||||
|
||||
val now = Calendar.getInstance().apply {
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val start = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, startStr[0].toInt())
|
||||
set(Calendar.MINUTE, startStr[1].toInt())
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val end = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, endStr[0].toInt())
|
||||
set(Calendar.MINUTE, endStr[1].toInt())
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
val now = Calendar.getInstance().apply { set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
|
||||
val start = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, startStr[0].toInt()); set(Calendar.MINUTE, startStr[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
|
||||
val end = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, endStr[0].toInt()); set(Calendar.MINUTE, endStr[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
|
||||
return if (end.before(start)) now.after(start) || now.before(end) else now.after(start) && now.before(end)
|
||||
} catch (e: Exception) { return false }
|
||||
}
|
||||
@@ -562,24 +769,36 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
val channel = NotificationChannel("auto_open_snaps", "Auto Open Snaps", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
enableVibration(false); setSound(null, null)
|
||||
runCatching {
|
||||
val channel = NotificationChannel("auto_open_snaps", "Auto Open Snaps", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
enableVibration(false); setSound(null, null)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}.onFailure {
|
||||
context.log.warn("Failed to create Auto Open Snaps notification channel: ${it.message}")
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun getSenderDisplayName(senderId: String): String = nameCache.getOrPut(senderId) {
|
||||
context.database.getFriendInfo(senderId)?.let { it.displayName ?: it.mutableUsername } ?: "Unknown"
|
||||
private fun getSenderDisplayName(senderId: String): String {
|
||||
// Memory Safety: Prevent cache bloat during high-volume bursts
|
||||
if (nameCache.size > 500) nameCache.clear()
|
||||
return nameCache.getOrPut(senderId) {
|
||||
context.database.getFriendInfo(senderId)?.let { it.displayName ?: it.mutableUsername } ?: "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConversationType(conversationId: String, senderId: String): String = conversationTypeCache.getOrPut("$conversationId:$senderId") {
|
||||
if (context.database.getDMOtherParticipant(conversationId) != null) "Friend DM"
|
||||
else context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: "Group Chat"
|
||||
private fun getConversationType(conversationId: String, senderId: String): String {
|
||||
// Memory Safety: Prevent cache bloat during high-volume bursts
|
||||
if (conversationTypeCache.size > 500) conversationTypeCache.clear()
|
||||
return conversationTypeCache.getOrPut("$conversationId:$senderId") {
|
||||
if (context.database.getDMOtherParticipant(conversationId) != null) "Friend DM"
|
||||
else context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: "Group Chat"
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSnapContentType(type: ContentType?): String = when (type) {
|
||||
ContentType.SNAP -> "Photo/Video"
|
||||
ContentType.EXTERNAL_MEDIA -> "Media"
|
||||
else -> "Snap"
|
||||
else -> context.translation["auto_open_snaps.content_type_snap"] ?: "Snap"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,6 +357,10 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
supported32BitAbis: List<String>? = null,
|
||||
supported64BitAbis: List<String>? = null
|
||||
) {
|
||||
val safeSupportedAbis = supportedAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_ABIS?.toList() ?: emptyList())
|
||||
val safeSupported32BitAbis = supported32BitAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_32_BIT_ABIS?.toList() ?: emptyList())
|
||||
val safeSupported64BitAbis = supported64BitAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_64_BIT_ABIS?.toList() ?: emptyList())
|
||||
|
||||
Build::class.java.fields.forEach { field ->
|
||||
if (!field.isAccessible) field.isAccessible = true
|
||||
runCatching {
|
||||
@@ -377,9 +381,9 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
"DISPLAY" -> if (overrideDisplay) runCatching { field.set(null, display) }
|
||||
"HOST" -> if (overrideHost) runCatching { field.set(null, host) }
|
||||
"TIME" -> if (overrideBuildTime) runCatching { field.setLong(null, buildTime) }
|
||||
"SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supportedAbis?.toTypedArray()) }
|
||||
"SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported32BitAbis?.toTypedArray()) }
|
||||
"SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported64BitAbis?.toTypedArray()) }
|
||||
"SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupportedAbis.toTypedArray()) }
|
||||
"SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupported32BitAbis.toTypedArray()) }
|
||||
"SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupported64BitAbis.toTypedArray()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ object RandomizedDeviceProfileStore {
|
||||
bluetoothMacAddress = bluetoothMac,
|
||||
ipAddress = ipAddress,
|
||||
wifiSsid = region.randomWifiSsid(),
|
||||
wifiRssi = random.nextInt(-72, -36),
|
||||
wifiRssi = randomInt(-72, -36),
|
||||
localeTag = locale.toLanguageTag(),
|
||||
countryIso = region.countryIso,
|
||||
timeZoneId = region.timeZoneId,
|
||||
@@ -450,7 +450,7 @@ object RandomizedDeviceProfileStore {
|
||||
|
||||
private fun randomPublicIpv4(prefixes: List<Int>? = null): String {
|
||||
val firstOctet = prefixes?.takeIf { it.isNotEmpty() }?.let { pick(it) } ?: run {
|
||||
generateSequence { random.nextInt(1, 224) }
|
||||
generateSequence { randomInt(1, 224) }
|
||||
.first { candidate ->
|
||||
candidate != 10 &&
|
||||
candidate != 127 &&
|
||||
@@ -459,13 +459,18 @@ object RandomizedDeviceProfileStore {
|
||||
candidate != 192
|
||||
}
|
||||
}
|
||||
val secondOctet = random.nextInt(1, 255)
|
||||
val thirdOctet = random.nextInt(1, 255)
|
||||
val fourthOctet = random.nextInt(2, 255)
|
||||
val secondOctet = randomInt(1, 255)
|
||||
val thirdOctet = randomInt(1, 255)
|
||||
val fourthOctet = randomInt(2, 255)
|
||||
val candidate = "$firstOctet.$secondOctet.$thirdOctet.$fourthOctet"
|
||||
return runCatching { InetAddress.getByName(candidate).hostAddress }.getOrDefault(candidate)
|
||||
}
|
||||
|
||||
private fun randomInt(minInclusive: Int, maxExclusive: Int): Int {
|
||||
require(maxExclusive > minInclusive)
|
||||
return minInclusive + random.nextInt(maxExclusive - minInclusive)
|
||||
}
|
||||
|
||||
private fun randomLong(minInclusive: Long, maxExclusive: Long): Long {
|
||||
require(maxExclusive > minInclusive)
|
||||
val bound = maxExclusive - minInclusive
|
||||
|
||||
@@ -7,7 +7,7 @@ import me.eternal.purrfectsnap.core.features.Feature
|
||||
|
||||
class DisableMetrics : Feature("DisableMetrics") {
|
||||
override fun init() {
|
||||
if (!context.config.global.disableMetrics.get()) return
|
||||
if (!context.config.global.disableMetrics.get() && context.config.global.performanceMode.profile.getNullable() == null) return
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { param ->
|
||||
val url = param.url
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.tweaks
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.transition.Transition
|
||||
import android.os.HandlerThread
|
||||
import android.os.Process
|
||||
import android.util.Base64
|
||||
import android.util.Range
|
||||
import android.view.View
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.Animation
|
||||
import android.widget.OverScroller
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import java.io.File
|
||||
import java.lang.Thread
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
|
||||
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 okhttp3.Dispatcher
|
||||
|
||||
class PerformanceMode : Feature("Performance Mode") {
|
||||
private data class SnapshotCell(
|
||||
val type: Int,
|
||||
val stringValue: String? = null,
|
||||
val longValue: Long? = null,
|
||||
val doubleValue: Double? = null,
|
||||
val blobValue: String? = null,
|
||||
)
|
||||
|
||||
private data class CursorSnapshot(
|
||||
val columns: List<String>,
|
||||
val rows: List<List<SnapshotCell>>,
|
||||
)
|
||||
|
||||
override fun init() {
|
||||
val profile = context.config.global.performanceMode.profile.getNullable() ?: return
|
||||
val isMaxProfile = profile == "max"
|
||||
val threadPriority = if (isMaxProfile) {
|
||||
Process.THREAD_PRIORITY_DISPLAY
|
||||
} else {
|
||||
Process.THREAD_PRIORITY_MORE_FAVORABLE
|
||||
}
|
||||
val minimumFrameRate = if (isMaxProfile) 60 else 45
|
||||
val durationScale = if (isMaxProfile) 0.35f else 0.55f
|
||||
val recyclerViewCacheSize = if (isMaxProfile) 64 else 32
|
||||
val maxRequests = if (isMaxProfile) 192 else 96
|
||||
val maxRequestsPerHost = if (isMaxProfile) 32 else 16
|
||||
val minimumCoreThreads = if (isMaxProfile) 16 else 8
|
||||
val prefetchItemCount = if (isMaxProfile) 24 else 12
|
||||
val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L
|
||||
val maxScrollDurationMs = if (isMaxProfile) 120 else 180
|
||||
val preferredRefreshRate = if (isMaxProfile) 120f else 90f
|
||||
|
||||
context.log.info(
|
||||
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate",
|
||||
"PerformanceMode"
|
||||
)
|
||||
|
||||
runCatching {
|
||||
ValueAnimator.setFrameDelay(0L)
|
||||
context.log.info("Applied ValueAnimator frame delay override: 0ms", "PerformanceMode")
|
||||
}
|
||||
|
||||
fun firstHitLogger(name: String): (String) -> Unit {
|
||||
val didLog = AtomicBoolean(false)
|
||||
return { details ->
|
||||
if (didLog.compareAndSet(false, true)) {
|
||||
context.log.info("First hit: $name | $details", "PerformanceMode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val handlerThreadConstructorLog = firstHitLogger("HandlerThread.constructor")
|
||||
val handlerThreadStartLog = firstHitLogger("HandlerThread.start")
|
||||
val threadStartLog = firstHitLogger("Thread.start")
|
||||
val executorLog = firstHitLogger("ThreadPoolExecutor.constructor")
|
||||
val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor")
|
||||
val animatorLog = firstHitLogger("ValueAnimator.getDurationScale")
|
||||
val animatorDurationLog = firstHitLogger("ValueAnimator.setDuration")
|
||||
val viewAnimatorDurationLog = firstHitLogger("ViewPropertyAnimator.setDuration")
|
||||
val transitionDurationLog = firstHitLogger("Transition.setDuration")
|
||||
val animationDurationLog = firstHitLogger("Animation.setDuration")
|
||||
val recyclerCtorLog = firstHitLogger("RecyclerView.constructor")
|
||||
val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter")
|
||||
val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager")
|
||||
val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase")
|
||||
val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase")
|
||||
val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate")
|
||||
val captureRequestLog = firstHitLogger("CaptureRequest.Builder.set")
|
||||
val sustainedModeLog = firstHitLogger("Window.setSustainedPerformanceMode")
|
||||
val refreshRateLog = firstHitLogger("Activity.preferredRefreshRate")
|
||||
val overScrollerLog = firstHitLogger("OverScroller.startScroll")
|
||||
val chatFeedCacheServeLog = firstHitLogger("ChatFeed.cacheServe")
|
||||
val chatFeedCacheRefreshLog = firstHitLogger("ChatFeed.cacheRefresh")
|
||||
val mapDialogLog = firstHitLogger("Dialog.show")
|
||||
val mapViewLog = firstHitLogger("MapView.constructor")
|
||||
val mapboxNetworkBlockLog = firstHitLogger("SnapMap.telemetryBlock")
|
||||
val mapCameraAnimLog = firstHitLogger("SnapMap.mapAnimatorDuration")
|
||||
val mapThreadLog = firstHitLogger("SnapMap.mapThread")
|
||||
val mapRendererFpsLog = firstHitLogger("SnapMap.mapRendererFps")
|
||||
|
||||
fun isPerformanceSensitiveThread(name: String?): Boolean {
|
||||
val normalizedName = name?.lowercase() ?: return false
|
||||
return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
|
||||
normalizedName.contains(it)
|
||||
}
|
||||
}
|
||||
|
||||
val performanceCacheDir = File(context.androidContext.filesDir, "performance_mode_cache").apply { mkdirs() }
|
||||
val chatFeedSnapshotFile = File(performanceCacheDir, "chat_feed_snapshot.json")
|
||||
|
||||
fun isChatFeedQuery(sql: String): Boolean {
|
||||
val normalized = sql.uppercase()
|
||||
if (!normalized.startsWith("SELECT")) return false
|
||||
val hitsFriendsFeedView = sql.contains("FriendsFeedView")
|
||||
val hitsFeedEntry = sql.contains("feed_entry") && (sql.contains("last_updated_timestamp") || sql.contains("displayInteractionType") || sql.contains("streak_count"))
|
||||
return (hitsFriendsFeedView || hitsFeedEntry) &&
|
||||
!normalized.contains("COUNT(") &&
|
||||
!normalized.contains("SELECT 0") &&
|
||||
!normalized.contains("WHERE KEY = ?") &&
|
||||
!normalized.contains("WHERE CLIENT_CONVERSATION_ID = ?")
|
||||
}
|
||||
|
||||
fun cursorCell(cursor: Cursor, index: Int): SnapshotCell {
|
||||
return when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_NULL -> SnapshotCell(Cursor.FIELD_TYPE_NULL)
|
||||
Cursor.FIELD_TYPE_INTEGER -> SnapshotCell(Cursor.FIELD_TYPE_INTEGER, longValue = cursor.getLong(index))
|
||||
Cursor.FIELD_TYPE_FLOAT -> SnapshotCell(Cursor.FIELD_TYPE_FLOAT, doubleValue = cursor.getDouble(index))
|
||||
Cursor.FIELD_TYPE_STRING -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
|
||||
Cursor.FIELD_TYPE_BLOB -> SnapshotCell(
|
||||
Cursor.FIELD_TYPE_BLOB,
|
||||
blobValue = Base64.encodeToString(cursor.getBlob(index), Base64.NO_WRAP)
|
||||
)
|
||||
else -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshotFromCursor(cursor: Cursor): CursorSnapshot {
|
||||
val columns = cursor.columnNames.toList()
|
||||
val rows = mutableListOf<List<SnapshotCell>>()
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
rows += columns.indices.map { index -> cursorCell(cursor, index) }
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
return CursorSnapshot(columns, rows)
|
||||
}
|
||||
|
||||
fun snapshotToMatrixCursor(snapshot: CursorSnapshot): MatrixCursor {
|
||||
return MatrixCursor(snapshot.columns.toTypedArray(), snapshot.rows.size).also { matrixCursor ->
|
||||
snapshot.rows.forEach { row ->
|
||||
matrixCursor.addRow(row.map { cell ->
|
||||
when (cell.type) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cell.longValue
|
||||
Cursor.FIELD_TYPE_FLOAT -> cell.doubleValue
|
||||
Cursor.FIELD_TYPE_BLOB -> cell.blobValue?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
else -> cell.stringValue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun readSnapshot(file: File): CursorSnapshot? {
|
||||
return runCatching {
|
||||
if (!file.exists()) return null
|
||||
context.gson.fromJson(file.readText(Charsets.UTF_8), CursorSnapshot::class.java)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun writeSnapshot(file: File, snapshot: CursorSnapshot) {
|
||||
runCatching {
|
||||
file.writeText(context.gson.toJson(snapshot), Charsets.UTF_8)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to persist friend list snapshot", it, "PerformanceMode")
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
|
||||
if (!isMaxProfile) return@subscribe
|
||||
val url = event.url
|
||||
if (url.contains("ami/friends")) {
|
||||
if (chatFeedSnapshotFile.exists()) {
|
||||
chatFeedSnapshotFile.delete()
|
||||
context.log.info("Invalidated chat feed snapshot after friends mutation sync", "PerformanceMode")
|
||||
}
|
||||
}
|
||||
if (url.contains("messaging") || url.contains("conversation") || url.contains("feed")) {
|
||||
if (chatFeedSnapshotFile.exists()) {
|
||||
chatFeedSnapshotFile.delete()
|
||||
context.log.info("Invalidated chat feed snapshot after messaging/feed network activity", "PerformanceMode")
|
||||
}
|
||||
}
|
||||
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
|
||||
event.canceled = true
|
||||
mapboxNetworkBlockLog("url=$url")
|
||||
}
|
||||
}
|
||||
|
||||
HandlerThread::class.java.hookConstructor(HookStage.BEFORE) { param ->
|
||||
if (param.args().size < 2) return@hookConstructor
|
||||
val threadName = param.argNullable<String>(0)
|
||||
if (!isPerformanceSensitiveThread(threadName)) return@hookConstructor
|
||||
param.setArg(1, threadPriority)
|
||||
handlerThreadConstructorLog("name=$threadName priority=$threadPriority")
|
||||
}
|
||||
|
||||
HandlerThread::class.java.hook("start", HookStage.AFTER) { param ->
|
||||
val thread = param.nullableThisObject<Any>() as? HandlerThread ?: return@hook
|
||||
if (!isPerformanceSensitiveThread(thread.name)) return@hook
|
||||
runCatching {
|
||||
val tid = thread.threadId
|
||||
if (tid > 0) {
|
||||
Process.setThreadPriority(tid, threadPriority)
|
||||
}
|
||||
}
|
||||
handlerThreadStartLog("name=${thread.name} tid=${thread.threadId} priority=$threadPriority")
|
||||
}
|
||||
|
||||
Thread::class.java.hook("start", HookStage.AFTER) { param ->
|
||||
val thread = param.thisObject<Thread>()
|
||||
if (!isPerformanceSensitiveThread(thread.name)) return@hook
|
||||
runCatching {
|
||||
thread.priority = Thread.MAX_PRIORITY
|
||||
}
|
||||
threadStartLog("name=${thread.name} priority=${thread.priority}")
|
||||
if ((thread.name ?: "").contains("map", ignoreCase = true) || (thread.name ?: "").contains("mapbox", ignoreCase = true)) {
|
||||
mapThreadLog("name=${thread.name} priority=${thread.priority}")
|
||||
}
|
||||
}
|
||||
|
||||
ThreadPoolExecutor::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val executor = param.thisObject<ThreadPoolExecutor>()
|
||||
runCatching {
|
||||
val targetCorePoolSize = executor.maximumPoolSize.coerceAtLeast(1).coerceAtMost(minimumCoreThreads.coerceAtLeast(executor.corePoolSize))
|
||||
if (executor.corePoolSize < targetCorePoolSize) {
|
||||
executor.corePoolSize = targetCorePoolSize
|
||||
}
|
||||
executor.allowCoreThreadTimeOut(false)
|
||||
executor.prestartAllCoreThreads()
|
||||
executorLog("core=${executor.corePoolSize} max=${executor.maximumPoolSize} active=${executor.activeCount}")
|
||||
}
|
||||
}
|
||||
|
||||
Dispatcher::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val dispatcher = param.thisObject<Dispatcher>()
|
||||
runCatching {
|
||||
dispatcher.maxRequests = maxRequests
|
||||
dispatcher.maxRequestsPerHost = maxRequestsPerHost
|
||||
dispatcherLog("maxRequests=${dispatcher.maxRequests} maxRequestsPerHost=${dispatcher.maxRequestsPerHost}")
|
||||
}
|
||||
}
|
||||
|
||||
ValueAnimator::class.java.hook("getDurationScale", HookStage.AFTER) { param ->
|
||||
param.setResult(durationScale)
|
||||
animatorLog("durationScale=$durationScale")
|
||||
}
|
||||
|
||||
ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
animatorDurationLog("requested=$original applied=${param.arg<Long>(0)}")
|
||||
val thisObject = param.nullableThisObject<Any>()?.javaClass?.name ?: ""
|
||||
if (thisObject.contains("map", ignoreCase = true)) {
|
||||
mapCameraAnimLog("owner=$thisObject requested=$original applied=${param.arg<Long>(0)}")
|
||||
}
|
||||
}
|
||||
|
||||
ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
viewAnimatorDurationLog("requested=$original applied=${param.arg<Long>(0)}")
|
||||
}
|
||||
|
||||
Transition::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
transitionDurationLog("requested=$original applied=${param.arg<Long>(0)}")
|
||||
}
|
||||
|
||||
Animation::class.java.hook("setDuration", HookStage.BEFORE) { param ->
|
||||
val original = param.arg<Long>(0)
|
||||
val updated = original.coerceAtMost(maxAnimationDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(0, updated)
|
||||
}
|
||||
animationDurationLog("requested=$original applied=${param.arg<Long>(0)}")
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
|
||||
recyclerView.overScrollMode = View.OVER_SCROLL_NEVER
|
||||
recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
if (isMaxProfile) {
|
||||
recyclerView.itemAnimator = null
|
||||
}
|
||||
recyclerCtorLog("cache=$recyclerViewCacheSize max=$isMaxProfile class=${recyclerView::class.java.name}")
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
|
||||
recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
if (isMaxProfile) {
|
||||
recyclerView.itemAnimator = null
|
||||
}
|
||||
recyclerAdapterLog("cache=$recyclerViewCacheSize adapter=${param.argNullable<Any>(0)?.javaClass?.name}")
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
val layoutManager = param.argNullable<Any>(0)
|
||||
when (layoutManager) {
|
||||
is LinearLayoutManager -> {
|
||||
layoutManager.isItemPrefetchEnabled = true
|
||||
layoutManager.initialPrefetchItemCount = prefetchItemCount
|
||||
}
|
||||
is StaggeredGridLayoutManager -> {
|
||||
layoutManager.isItemPrefetchEnabled = true
|
||||
layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
|
||||
}
|
||||
}
|
||||
recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount")
|
||||
}
|
||||
|
||||
fun SQLiteDatabase.applyPerformancePragmas() {
|
||||
runCatching { execSQL("PRAGMA synchronous = NORMAL") }
|
||||
runCatching { execSQL("PRAGMA temp_store = MEMORY") }
|
||||
runCatching { execSQL("PRAGMA cache_size = -32768") }
|
||||
runCatching { execSQL("PRAGMA mmap_size = 268435456") }
|
||||
runCatching { execSQL("PRAGMA journal_size_limit = 1048576") }
|
||||
runCatching { execSQL("PRAGMA optimize") }
|
||||
}
|
||||
|
||||
SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param ->
|
||||
(param.getResult() as? SQLiteDatabase)?.also {
|
||||
it.applyPerformancePragmas()
|
||||
sqliteOpenLog("path=${param.argNullable<Any>(0)}")
|
||||
}
|
||||
}
|
||||
|
||||
SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param ->
|
||||
(param.getResult() as? SQLiteDatabase)?.also {
|
||||
it.applyPerformancePragmas()
|
||||
sqliteCreateLog("path=${param.argNullable<Any>(0)}")
|
||||
}
|
||||
}
|
||||
|
||||
MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param ->
|
||||
val currentRate = param.arg<Int>(0)
|
||||
if (currentRate < minimumFrameRate) {
|
||||
param.setArg(0, minimumFrameRate)
|
||||
}
|
||||
mediaRecorderLog("requested=$currentRate applied=${param.arg<Int>(0)}")
|
||||
}
|
||||
|
||||
OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param ->
|
||||
if (param.args().size >= 5) {
|
||||
val original = param.arg<Int>(4)
|
||||
val updated = original.coerceAtMost(maxScrollDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(4, updated)
|
||||
}
|
||||
overScrollerLog("requested=$original applied=${param.arg<Int>(4)}")
|
||||
}
|
||||
}
|
||||
|
||||
OverScroller::class.java.hook("fling", HookStage.BEFORE) { param ->
|
||||
if (param.args().size >= 10) {
|
||||
val overX = param.arg<Int>(8)
|
||||
val overY = param.arg<Int>(9)
|
||||
if (overX != 0) param.setArg(8, 0)
|
||||
if (overY != 0) param.setArg(9, 0)
|
||||
}
|
||||
}
|
||||
|
||||
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
|
||||
val key = param.arg<CaptureRequest.Key<*>>(0)
|
||||
captureRequestLog("key=${key.name} value=${param.argNullable<Any>(1)}")
|
||||
}
|
||||
|
||||
fun applyActivityPerformanceTuning(activity: Activity) {
|
||||
runCatching {
|
||||
activity.window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
val display = activity.display
|
||||
val targetRefreshRate = display?.supportedModes?.maxByOrNull { it.refreshRate }?.refreshRate
|
||||
?.coerceAtLeast(preferredRefreshRate) ?: preferredRefreshRate
|
||||
activity.window.attributes = activity.window.attributes.apply {
|
||||
this.preferredRefreshRate = targetRefreshRate
|
||||
}
|
||||
refreshRateLog("activity=${activity::class.java.name} refreshRate=$targetRefreshRate")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) {
|
||||
runCatching {
|
||||
activity.window.setSustainedPerformanceMode(true)
|
||||
sustainedModeLog("activity=${activity::class.java.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
applyActivityPerformanceTuning(it)
|
||||
}
|
||||
|
||||
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
|
||||
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
|
||||
val window = dialog.window ?: return@hook
|
||||
runCatching {
|
||||
window.setWindowAnimations(0)
|
||||
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
window.attributes = window.attributes.apply {
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
||||
}
|
||||
if (dialog::class.java.name.contains("map", ignoreCase = true) || dialog::class.java.name.contains("snap", ignoreCase = true)) {
|
||||
mapDialogLog("class=${dialog::class.java.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.mapbox.mapboxsdk.maps.MapView").hookConstructor(HookStage.AFTER) { param ->
|
||||
val mapView = param.nullableThisObject<Any>() as? View ?: return@hookConstructor
|
||||
mapView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
mapView.overScrollMode = View.OVER_SCROLL_NEVER
|
||||
mapViewLog("class=${mapView::class.java.name}")
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.mapbox.mapboxsdk.maps.renderer.MapRenderer").hook("setMaximumFps", HookStage.BEFORE) { param ->
|
||||
val requested = param.arg<Int>(0)
|
||||
val applied = requested.coerceAtLeast(120)
|
||||
if (applied != requested) {
|
||||
param.setArg(0, applied)
|
||||
}
|
||||
mapRendererFpsLog("requested=$requested applied=${param.arg<Int>(0)}")
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.BEFORE) { param ->
|
||||
if (!isMaxProfile) return@hook
|
||||
val sql = param.argNullable<String>(1) ?: return@hook
|
||||
if (!isChatFeedQuery(sql)) return@hook
|
||||
readSnapshot(chatFeedSnapshotFile)?.let { snapshot ->
|
||||
param.setResult(snapshotToMatrixCursor(snapshot))
|
||||
chatFeedCacheServeLog("rows=${snapshot.rows.size} file=${chatFeedSnapshotFile.name}")
|
||||
}
|
||||
}
|
||||
|
||||
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.AFTER) { param ->
|
||||
if (!isMaxProfile) return@hook
|
||||
val sql = param.argNullable<String>(1) ?: return@hook
|
||||
if (!isChatFeedQuery(sql)) return@hook
|
||||
val cursor = param.getResult() as? Cursor ?: return@hook
|
||||
val snapshot = snapshotFromCursor(cursor)
|
||||
writeSnapshot(chatFeedSnapshotFile, snapshot)
|
||||
param.setResult(snapshotToMatrixCursor(snapshot))
|
||||
runCatching { cursor.close() }
|
||||
chatFeedCacheRefreshLog("rows=${snapshot.rows.size} file=${chatFeedSnapshotFile.name}")
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to install chat feed cache hooks", it, "PerformanceMode")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.6.3
|
||||
APP_VERSION_CODE=316
|
||||
APP_VERSION_NAME=1.6.6
|
||||
APP_VERSION_CODE=322
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
Reference in New Issue
Block a user