9 Commits

Author SHA1 Message Date
ΞTΞRNAL
b44558babd fix(PR): Auto Open stabilization and Spotlight/Story video download fixes by Kaladin
Auto Open stabilization and Spotlight/Story video download fixes.
2026-04-05 15:37:00 +05:30
ΞTΞRNAL
e97a37d18e v1.6.8 2026-04-05 15:35:39 +05:30
DarkKnight2122
281d55689a Spotlight/Story video download fix 2026-04-05 03:28:17 +05:30
DarkKnight2122
7475998961 Optimize background engine & sync with Notifications for instant previews 2026-04-05 00:53:56 +05:30
ΞTΞRNAL
f01b6c2f9a add fresh changelogs
Updated changelog for version 1.6.6 with multiple fixes and new features, including pre-fetch snaps toggle and disk optimization improvements.
2026-04-02 20:48:00 +05:30
ΞTΞRNAL
98f8d6ebea fix(PR): auto open notification fix by Kaladin
fix: auto open notification fix
2026-04-02 20:39:32 +05:30
DarkKnight2122
015226cb40 fix: auto open notification fix 2026-04-02 19:43:59 +05:30
ΞTΞRNAL
ad06b0dffb v1.6.6 2026-04-02 19:00:08 +05:30
ΞTΞRNAL
4b4d64e8eb fix version bump 2026-04-02 14:18:43 +05:30
19 changed files with 1010 additions and 576 deletions

View File

@@ -146,8 +146,8 @@ class FFMpegProcessor(
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"

View File

@@ -466,22 +466,26 @@ private class DialogWrapper(
this.onDismissRequest = onDismissRequest
this.properties = properties
setLayoutDirection(layoutDirection)
if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
val dialogWindow = window
val canUpdateWindowLayout = dialogWindow?.decorView?.let { decorView ->
isShowing && decorView.isAttachedToWindow && decorView.windowToken != null
} == true
if (canUpdateWindowLayout && properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
// Undo fixed size in internalOnLayout, which would suppress size changes when
// usePlatformDefaultWidth is true.
window?.setLayout(
dialogWindow.setLayout(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
if (canUpdateWindowLayout && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
@OptIn(ExperimentalComposeUiApi::class)
if (properties.decorFitsSystemWindows) {
window?.setSoftInputMode(defaultSoftInputMode)
dialogWindow?.setSoftInputMode(defaultSoftInputMode)
} else {
@Suppress("DEPRECATION")
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
dialogWindow?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
}

View File

@@ -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.4").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("318").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.8").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("324").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
// Include version code so each release has a different hash; use random for uniqueness within same version.

View File

@@ -1,3 +1,27 @@
## v1.6.8
- Fix: Many improvements to the Performance Mode feature(Max, turned on by Default), changes are pretty noticeable: faster loading of chats, long group messages optimizations, many snapmap optimizations
- Fix: Crash issues for some devices
- Fix: Block Ads
- Fix: Snapchat automatically restarting if kept idle
- Fix: Feed Flickering issue for some devices
- Fix: Implemented Dual-Thread Sync Architecture with a 1,000 depth that proactively scans the database for missed history, thus resolving the "failed to open..." and "Tap to load" snaps before marking them, clearing historical gaps automatically(tq to Kaladin)
- New: Implemented Screen Guard where Auto Open Engine now detects screen state and stops all notification work while the device is locked, thus reducing excessive battery drain and heat spike during background processing(tq to Kaladin)
- Fix: Disk read/write frequency increased from 1s to 5 minutes, to eliminate constant background disk friction, thus reducing overheating of the device while processing(tq to Kaladin)
- Fix: Slid chat switching delays from 2.0s down to 40ms, thus achieving near-instant processing of 1000+ snap bursts(tq to Kaladin)
- Fix: Removed "Auto Open pause while Gaming" toggle(tq to Kaladin)
- Fix: Reworked "Snap Pre-Fetch" toggle to the global Messaging settings page(tq to Kaladin)
- Fix: Refactored Auto Open engine notification cards to declutter it and removed redundant stats(tq to Kaladin)
- Fix: Corrected speed labels in the Auto Open notification to show "Idle" when monitoring and accurate notions (Full Speed/Throttled) when active(tq to Kaladin)
## v1.6.6
- Fix: Persistent auto open snap disabled notification(tq to Kaladin)
- Fix: Crash issues for some devices
- 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!

View File

@@ -1481,6 +1481,7 @@
"name": "Bypass Message Action Restrictions",
"description": "Allows you to react to a snap without having opened it or to save an unsaveable message"
},
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
"remove_groups_locked_status": {
"name": "Remove Groups Locked Status",
"description": "Allows you to view group information after being kicked"
@@ -1696,6 +1697,7 @@
"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" },
"content_type_snap": "Snap",
"only_when_idle": {
"name": "Auto Open Schedule",
"description": "Configure a specific time window where the engine will throttle its speed."
@@ -1704,10 +1706,14 @@
"name": "Auto Open Scheduler",
"description": "Define the start and end times for scheduled throttled processing."
},
"pause_during_gaming": { "name": "Pause Auto Open During Gaming", "description": "Automatically slow down processing when a resource intensive app or a game is in the foreground" },
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
}
},
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
"instant_translation": {
"name": "Message Translator",
"description": "Configure the message translator"
},
"auto_delete_sent_messages": {
"name": "Auto Delete Sent Messages",
"description": "Automatically deletes sent messages after a specified time period",
@@ -3675,12 +3681,13 @@
"failed_gallery_toast": "Failed saving to gallery {error}",
"dash_no_chapter": "No chapter found",
"dash_dialog": {
"title": "Download dash media",
"title": "DASH Download",
"download_all": "Download All",
"segment_text": "Segment {from} - {to}"
"snap_text": "Snap {from} - {to}"
},
"story_snap_dialog": {
"title": "Download story snaps",
"download_all": "Download All",
"select_all": "Select All",
"deselect_all": "Deselect All",
"snap_item": "Snap {index} of {total}"

View File

@@ -180,9 +180,7 @@ class MessagingTweaks : ConfigContainer() {
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 pauseDuringGaming = boolean("pause_during_gaming", false)
val safeProcessing = boolean("safe_processing", true)
val onlyWhenIdle = boolean("only_when_idle", false)
val sleepWindow = string("sleep_window", defaultValue = "23:00-07:00") {
@@ -209,6 +207,38 @@ class MessagingTweaks : ConfigContainer() {
val showNotification = boolean("show_notification", defaultValue = true)
}
class InstantTranslationConfig : ConfigContainer(hasGlobalState = true) {
val enabled = boolean("enabled", false)
val sourceLanguage = string("source_language", defaultValue = "auto") {
inputCheck = { it.isNotBlank() }
}
val targetLanguage = string("target_language", defaultValue = "en") {
inputCheck = { it.isNotBlank() }
}
val showOriginal = boolean("show_original", defaultValue = true)
val showTranslation = boolean("show_translation", defaultValue = true)
val translationPosition = unique("translation_position", "above", "below", "inline") {
customOptionTranslationPath = "translation_position"
}.apply { set("below") }
val autoTranslate = boolean("auto_translate", defaultValue = true)
val translateOnTap = boolean("translate_on_tap", defaultValue = false)
val pauseOnError = boolean("pause_on_error", defaultValue = true)
val maxRetries = integer("max_retries", defaultValue = 3) {
inputCheck = { it.toIntOrNull()?.coerceIn(1, 10) != null }
}
val retryDelay = integer("retry_delay", defaultValue = 1000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(500) != null }
}
val supportedLanguages = multiple("supported_languages",
"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr", "nl", "pl", "sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "hr", "sk", "sl", "et", "lv", "lt", "mt", "ga", "cy"
) {
customOptionTranslationPath = "language_codes"
}.apply {
set(mutableListOf("en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr"))
}
}
val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() }
val anonymousStoryViewing = boolean("anonymous_story_viewing")
val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() }
@@ -296,40 +326,9 @@ class MessagingTweaks : ConfigContainer() {
val doubleTapChatActionCustomEmoji = string("double_tap_chat_action_custom_emoji") {
inputCheck = { it.length == 2 && it.toByteArray(Charsets.UTF_8).size >= 4 } }
val autoReply = container("auto_reply", AutoReplyConfig()) { requireRestart() }
val autoOpenSnaps = container("auto_open_snaps", AutoOpenSnapsConfig()) { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
val autoDeleteSentMessages = container("auto_delete_sent_messages", AutoDeleteSentMessagesConfig()) { requireRestart() }
class InstantTranslationConfig : ConfigContainer(hasGlobalState = true) {
val enabled = boolean("enabled", false)
val sourceLanguage = string("source_language", defaultValue = "auto") {
inputCheck = { it.isNotBlank() }
}
val targetLanguage = string("target_language", defaultValue = "en") {
inputCheck = { it.isNotBlank() }
}
val showOriginal = boolean("show_original", defaultValue = true)
val showTranslation = boolean("show_translation", defaultValue = true)
val translationPosition = unique("translation_position", "above", "below", "inline") {
customOptionTranslationPath = "translation_position"
}.apply { set("below") }
val autoTranslate = boolean("auto_translate", defaultValue = true)
val translateOnTap = boolean("translate_on_tap", defaultValue = false)
val pauseOnError = boolean("pause_on_error", defaultValue = true)
val maxRetries = integer("max_retries", defaultValue = 3) {
inputCheck = { it.toIntOrNull()?.coerceIn(1, 10) != null }
}
val retryDelay = integer("retry_delay", defaultValue = 1000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(500) != null }
}
val supportedLanguages = multiple("supported_languages",
"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr", "nl", "pl", "sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "hr", "sk", "sl", "et", "lv", "lt", "mt", "ga", "cy"
) {
customOptionTranslationPath = "language_codes"
}.apply {
set(mutableListOf("en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr"))
}
}
val autoOpenSnaps = container("auto_open_snaps", AutoOpenSnapsConfig()) { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
val preFetchSnaps = boolean("pre_fetch_snaps", false)
val instantTranslation = container("instant_translation", InstantTranslationConfig()) { requireRestart() }
}

View File

@@ -72,24 +72,10 @@ enum class FileType(
if (majorBrand in imageBrands) return false
return majorBrand in setOf(
"mp41",
"mp42",
"isom",
"iso2",
"iso3",
"iso4",
"iso5",
"iso6",
"avc1",
"dash",
"cmfc",
"msnv",
"3gp4",
"3gp5",
"3gp6",
"3g2a",
"3g2b"
) || majorBrand.isNotEmpty() // FALLBACK: If it has the ftyp box and isn't a known image brand, it's a video
"mp41", "mp42", "isom", "iso2", "iso3", "iso4", "iso5", "iso6",
"avc1", "dash", "cmfc", "msnv", "3gp4", "3gp5", "3gp6", "3g2a", "3g2b",
"mp4v", "mp4a", "m4v ", "m4a ", "f4v ", "f4a "
) || majorBrand.isNotEmpty()
}
fun fromFile(file: File): FileType {

View File

@@ -168,11 +168,10 @@ class BridgeClient(
Log.d("BridgeClient", "service is dead, restarting")
val canLoad = connect {
Log.e("BridgeClient", "connection failed", it)
context.softRestartApp()
}
if (canLoad != true) {
Log.e("BridgeClient", "failed to reconnect to service, result=$canLoad")
context.softRestartApp()
return@runBlocking
}
}
}
@@ -188,9 +187,6 @@ class BridgeClient(
block()
}.getOrElse {
Log.e("BridgeClient", "service call failed", it)
if (it is DeadObjectException) {
context.softRestartApp()
}
throw it
}
}

View File

@@ -80,6 +80,7 @@ class FeatureManager(
MessageLogger(),
ConvertMessageLocally(),
SnapchatPlus(),
AdBlockFix(),
DisableMetrics(),
EndpointsBlocker(),
PreventMessageSending(),

View File

@@ -65,11 +65,11 @@ class ConfigurationOverride : Feature("Configuration Override") {
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",
@@ -93,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 })
}
@@ -105,6 +109,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
{ true })
overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() },
{ true })
overrideProperty("ENABLE_MESSAGE_WINDOW_MANAGER", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("ENABLE_SIMPLE_CONVERSATION_RESET", { context.config.global.performanceMode.profile.getNullable() == "max" },
{ false })
overrideProperty("PREVIEW_PRELOAD_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("BUFFERED_VIDEO_RECORDING_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
@@ -116,27 +124,32 @@ class ConfigurationOverride : Feature("Configuration Override") {
arrayOf(
"FEATURE_PRELOADER",
"USER_STORY_PRELOAD",
"STARTUP_LENS_ACTIVATOR",
"LENSES_PREVIEW_ACTIVATOR",
"THUMBNAIL_PRESENTER_ACTIVATOR",
"SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR",
"SERVER_PREFETCH",
"SERVER_PREFETCH_WITH_COF",
"DISCOVER_FEED_PERFORMANCE",
"DISCOVER_FEED_STORY_PREFETCH",
"DISCOVER_FEED_THUMBNAILS",
"LOGIN_PRELOAD",
"PREFETCH_REPO_SUBSCRIBE_ON_CPU",
"COMPUTE_FEED_CACHE_WITH_TTL",
"COMPUTE_FEED_NETWORK_WITH_CACHE",
"OPERA_WARMUP",
"REFACTORED_WITH_WARMUP_LENS",
"SHOW_PREFETCH",
).forEach { key ->
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() != null }, { true })
}
arrayOf(
"USER_STORY_PRELOAD",
"STARTUP_LENS_ACTIVATOR",
"LENSES_PREVIEW_ACTIVATOR",
"THUMBNAIL_PRESENTER_ACTIVATOR",
"SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR",
"DISCOVER_FEED_STORY_PREFETCH",
"DISCOVER_FEED_THUMBNAILS",
"REFACTORED_WITH_WARMUP_LENS",
).forEach { key ->
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() == "max" }, { false })
}
overrideProperty("LOAD_LATENCY_TRACKER_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ false })
overrideProperty("ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },

View File

@@ -166,7 +166,29 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
callback = object: DownloadCallback.Stub() {
override fun onSuccess(outputFile: String) {
if (!downloadLogging.contains("success")) return
context.log.verbose("onSuccess: outputFile=$outputFile")
var finalOutputFile = outputFile
runCatching {
val file = java.io.File(outputFile)
if (file.exists()) {
val header = file.inputStream().use { input ->
val buffer = ByteArray(16)
input.read(buffer)
buffer
}
val fileType = FileType.fromByteArray(header)
if (fileType.isVideo && !outputFile.endsWith(".mp4", ignoreCase = true)) {
val newPath = outputFile.removeSuffix(".dat") + ".mp4"
val newFile = java.io.File(newPath)
if (file.renameTo(newFile)) {
finalOutputFile = newPath
context.log.verbose("corrected video extension: $outputFile -> $newPath")
}
}
}
}
context.log.verbose("onSuccess: outputFile=$finalOutputFile")
context.inAppOverlay.showStatusToast(
icon = Icons.Outlined.DownloadDone,
durationMs = 1300,
@@ -685,74 +707,120 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
context.runOnUiThread {
val selectedChapters = mutableListOf<Int>()
val dialogTranslation = translations.getCategory("dash_dialog")
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)
}
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply {
setTitle(dialogTranslation["title"])
setMultiChoiceItems(
chapters.map { dialogTranslation.format("segment_text", "from" to prettyPrintTime(it.offset), "to" to prettyPrintTime(it.offset + (it.duration ?: 0))) }.toTypedArray(),
List(chapters.size) { index ->
if (currentChapterIndex == index) {
selectedChapters.add(index)
true
} else false
}.toBooleanArray()
) { _, which, isChecked ->
if (isChecked) {
selectedChapters.add(which)
} else if (selectedChapters.contains(which)) {
selectedChapters.remove(which)
}
}
setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() }
setNeutralButton(dialogTranslation["download_all"]) { _, _ ->
provideDownloadManagerClient(
mediaIdentifier = paramMap["STORY_ID"].toString(),
downloadSource = MediaDownloadSource.PUBLIC_STORY,
mediaAuthor = storyName
).downloadDashMedia(playlistUrl, 0, null)
}
setPositiveButton(this@MediaDownloader.context.translation["button.download"]) { _, _ ->
val groups = mutableListOf<MutableList<SnapChapterInfo>>()
val cancelStr = context.translation["button.cancel"]
val downloadStr = context.translation["button.download"]
var lastChapterIndex = -1
// group consecutive chapters
chapters.forEachIndexed { index, snapChapter ->
lastChapterIndex = if (selectedChapters.contains(index)) {
if (lastChapterIndex == -1) {
groups.add(mutableListOf())
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
val selected = remember { mutableStateListOf<Int>().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
)
}
}
}
groups.last().add(snapChapter)
index
} else {
-1
}
}
groups.forEach { group ->
val firstChapter = group.first()
val lastChapter = group.last()
val duration = if (firstChapter == lastChapter) {
firstChapter.duration
} else {
lastChapter.duration?.let { lastChapter.offset - firstChapter.offset + it }
}
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
)
}
provideDownloadManagerClient(
mediaIdentifier = "${paramMap["STORY_ID"]}-${firstChapter.offset}-${lastChapter.offset}",
downloadSource = MediaDownloadSource.PUBLIC_STORY,
mediaAuthor = storyName,
forceAllowDuplicate = forceAllowDuplicate,
).downloadDashMedia(
playlistUrl,
firstChapter.offset.plus(100),
duration
)
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<MutableList<SnapChapterInfo>>()
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()

View File

@@ -22,9 +22,11 @@ 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
@@ -32,6 +34,7 @@ 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
@@ -46,79 +49,76 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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"
private const val PREF_SAVED_QUEUE = "auto_open_saved_queue"
}
private val gson = Gson()
private val isPaused = AtomicBoolean(false)
private val totalProcessed = AtomicInteger(0)
private val totalDetected = AtomicInteger(0)
private val sessionProcessed = AtomicInteger(0)
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 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>()
private val metadataCache = Collections.synchronizedMap(object : LinkedHashMap<String, String>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): 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 batchSnapCount = 0
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()
// 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) }
}
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(),
var retryCount: Int = 0
val timestamp: Long = System.currentTimeMillis()
)
private val autoOpenInterface = object : AutoOpenInterface.Stub() {
override fun getProcessedCount(): Int = totalProcessed.get()
override fun getProcessedCount(): Int = sessionProcessed.get()
override fun getQueueItems(): List<String> = synchronized(queuedSnaps) { queuedSnaps.map { gson.toJson(it) } }
override fun reset() {
clearInternalState()
}
override fun reset() { clearInternalState() }
}
private fun clearInternalState() {
resetPersistence()
totalProcessed.set(0)
totalDetected.set(0)
sessionProcessed.set(0)
totalProcessed.set(0)
totalPausedDuration.set(0)
lastPausedAt.set(0)
sessionStartTime.set(System.currentTimeMillis())
@@ -127,15 +127,16 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
openedSnaps.clear()
prefs.edit()
.putInt(PREF_TOTAL_OPENED, 0)
.putInt(PREF_TOTAL_DETECTED, 0)
.putLong(PREF_SESSION_START, System.currentTimeMillis())
.remove(PREF_SAVED_QUEUE)
.remove(PREF_TOTAL_OPENED)
.apply()
updateStatusNotification()
updateStatusNotification(force = true)
}
fun getSnapMetadata(clientMessageId: Long): SnapQueueItem? = synchronized(queuedSnaps) { queuedSnaps.find { it.messageId == clientMessageId } }
fun getInterface(): AutoOpenInterface = autoOpenInterface
private val actionReceiver = object : BroadcastReceiver() {
@@ -144,22 +145,16 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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())
}
if (paused) lastPausedAt.set(System.currentTimeMillis())
else {
if (lastPausedAt.get() > 0) totalPausedDuration.addAndGet(System.currentTimeMillis() - lastPausedAt.get())
snapQueue.tryEmit(System.currentTimeMillis())
}
updateStatusNotification()
}
ACTION_CLEAR_QUEUE -> {
synchronized(queuedSnaps) { queuedSnaps.clear() }
synchronized(deadLetterQueue) { deadLetterQueue.clear() }
triggerLazySave()
updateStatusNotification()
updateStatusNotification(force = true)
}
ACTION_CLEAR_QUEUE -> clearInternalState()
Intent.ACTION_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) }
Intent.ACTION_SCREEN_OFF -> isScreenOn.set(false)
}
}
}
@@ -167,7 +162,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
override fun init() {
val messaging = context.feature(Messaging::class)
restorePersistence()
hasBeenActive.set(true)
hasBeenActive.set(config.globalState == true)
if (config.allowRunningInBackground.get()) {
acquireWakeLock()
@@ -175,38 +170,26 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
hook("appStateChanged", HookStage.BEFORE) { param ->
if (config.allowRunningInBackground.get()) {
val state = param.arg<Any>(0).toString()
if (state == "INACTIVE" || state == "BACKGROUND") {
param.setResult(null)
}
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<Any>(), activeState)
}
val activeState = enumClass.enumConstants?.firstOrNull { it.toString() == "ACTIVE" || it.toString() == "FOREGROUND" }
if (activeState != null) method.invoke(param.thisObject<Any>(), 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)
}
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(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() {
@@ -214,12 +197,9 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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.")
isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis()
} else if (isThermalThrottled && temp <= 36f && System.currentTimeMillis() - lastThermalThrottleAt > 600000) {
isThermalThrottled = false
context.log.info("[THERMAL] Device cooled to ${temp}C. Resuming full speed.")
}
}
}
@@ -233,121 +213,69 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
context.androidContext.registerReceiver(batteryReceiver, filter)
}
if (synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) {
snapQueue.tryEmit(System.currentTimeMillis())
}
if (synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) snapQueue.tryEmit(System.currentTimeMillis())
// Watchdog Loop
context.coroutineScope.launch(Dispatchers.Default) {
while (isActive) {
if (config.globalState != true) {
shutdownFeature()
break
}
if (config.globalState != true) { shutdownFeature(); break }
val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size }
if (remainingCount == 0 && sessionProcessed.get() > 0) {
sessionProcessed.set(0)
triggerLazySave()
}
if (remainingCount > 0) {
lastQueueActivity = System.currentTimeMillis()
acquireWakeLock()
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())
}
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()
if (System.currentTimeMillis() - lastQueueActivity > 300000) {
startWakeLockCooldown()
}
}
updateStatusNotification()
delay(5000)
}
}
context.coroutineScope.launch(Dispatchers.Default, CoroutineStart.UNDISPATCHED) {
snapQueue.collect { _ ->
// Processing Loop
context.coroutineScope.launch(Dispatchers.Default) {
snapQueue.collect {
if (isPaused.get() || config.globalState != true) return@collect
while (isActive && config.globalState == true) {
if (isPaused.get()) {
delay(1000)
continue
}
val item = synchronized(queuedSnaps) {
if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null
} ?: break
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 isGaming = isGaming()
val inSleepWindow = if (config.onlyWhenIdle.get()) isInsideSleepWindow() else false
val onlyIdle = config.onlyWhenIdle.get()
val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false
when {
config.onlyOnWifi.get() && !isWifi -> {
currentStatusText = context.translation["auto_open_snaps.only_on_wifi.name"] ?: "Waiting for WiFi..."
currentSpeedText = "Throttled"
isCurrentlyWaiting = true
delay(5000)
currentStatusText = "Waiting for 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 = "Throttled"
isCurrentlyWaiting = true
delay(5000)
onlyIdle && !isIdle && !inSleepWindow -> {
currentStatusText = "Waiting for idle..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000)
}
config.pauseDuringGaming.get() && isGaming -> {
currentStatusText = context.translation["auto_open_snaps.pause_during_gaming.name"] ?: "Paused (Gaming Mode)"
currentSpeedText = "Paused"
isCurrentlyWaiting = true
delay(60000)
}
else -> {
resourceWaiting = false
currentSpeedText = if (inSleepWindow || isThermalThrottled) "Throttled" else "Full Speed"
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
}
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 || 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 = "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))
}
}
// TIMING: 40ms switch
if (lastConversationId != null && lastConversationId != item.conversationId) { delay(40) }
lastConversationId = item.conversationId
currentStatusText = context.translation["auto_open_snaps.status_active"] ?: "Opening snap..."
updateStatusNotification()
currentStatusText = "Active"; updateStatusNotification()
var success = false
val startTime = System.currentTimeMillis()
@@ -355,234 +283,222 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
for (i in 0 until config.retryAttempts.get()) {
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 = "Waiting for UI..."
isCurrentlyWaiting = true
updateStatusNotification()
delay(2000)
// Bridge Handshake
if (messaging.conversationManager == null) {
runCatching { context.messagingBridge.triggerSessionStart() }
var waitTime = 0
while (messaging.conversationManager == null && waitTime < 2000) { delay(100); waitTime += 100 }
}
if (isPaused.get() || config.globalState != true) break
isCurrentlyWaiting = false
success = performOpen(messaging, item)
if (success) {
totalProcessed.incrementAndGet()
sessionProcessed.incrementAndGet()
synchronized(snapTimestamps) {
snapTimestamps.addLast(System.currentTimeMillis())
if (snapTimestamps.size > 100) snapTimestamps.removeFirst()
}
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())
val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
val threshold = if (remaining > 100) 100 else 25
if (processedSinceLastSave.incrementAndGet() >= threshold) {
triggerLazySave()
processedSinceLastSave.set(0)
}
delay(5)
break
}
if (i < config.retryAttempts.get() - 1) {
currentStatusText = context.translation["auto_open_snaps.status_retrying"] ?: "Retrying..."
updateStatusNotification()
delay(currentRetryDelay); currentRetryDelay *= 2
currentStatusText = "Retrying..."; updateStatusNotification(); delay(currentRetryDelay); currentRetryDelay *= 2
}
}
if (!success && !isPaused.get()) {
currentStatusText = context.translation["auto_open_snaps.status_failed"]?.replace("{sender}", item.senderName) ?: "Failed to open"
updateStatusNotification()
// 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)
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() }) {
delay(500)
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
currentStatusText = context.translation["auto_open_snaps.status_monitoring"] ?: "Monitoring..."
isCurrentlyWaiting = false
triggerLazySave()
updateStatusNotification()
}
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
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
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 (event.message.messageMetadata?.openedBy?.any { it.toString() == context.database.myUserId } == true) 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()
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()
}
if (!isPaused.get()) {
snapQueue.tryEmit(System.currentTimeMillis())
}
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
withTimeoutOrNull(5000) {
suspendCancellableCoroutine<Boolean> { cont ->
runCatching {
manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result ->
cont.resume(result == null || result == "DUPLICATEREQUEST")
suspendCancellableCoroutine<Boolean> { 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) }
}
} ?: false
}
private fun formatDuration(millis: Long): String {
val s = (millis / 1000) % 60; val m = (millis / 60000) % 60; val h = millis / 3600000
return when { h > 0 -> "${h}h ${m}m ${s}s"; m > 0 -> "${m}m ${s}s"; else -> "${s}s" }
}
}.onFailure { cont.resume(false) }
}
}
private fun getSnapsPerSecond(): Double {
val now = System.currentTimeMillis()
val window = 5000L
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 }; return (snapTimestamps.size.toDouble() / (window / 1000.0))
}
}
private fun updateStatusNotification() {
val currentTime = System.currentTimeMillis()
val lastUpdate = lastNotificationUpdate.get()
if ((currentTime - lastUpdate) < notificationUpdateDelay) {
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) {
if (pendingNotificationUpdate.compareAndSet(false, true)) {
context.coroutineScope.launch {
delay(notificationUpdateDelay - (currentTime - lastUpdate))
pendingNotificationUpdate.set(false)
updateStatusNotificationInternal()
}
context.coroutineScope.launch { delay(notificationUpdateDelay - (currentTime - lastUpdate)); pendingNotificationUpdate.set(false); updateStatusNotificationInternal() }
}
return
}
lastNotificationUpdate.set(currentTime)
updateStatusNotificationInternal()
lastNotificationUpdate.set(currentTime); updateStatusNotificationInternal()
}
private var lastNotificationStateHash: Int = 0
private fun updateStatusNotificationInternal() {
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 sessionTotal = processed + remaining
val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0
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).setAutoCancel(!isWorking).setOnlyAlertOnce(true)
.setGroup(NOTIFICATION_GROUP_KEY).setGroupSummary(false)
.setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY).setGroupSummary(false)
val eta = if (isWorking && !isCurrentlyWaiting && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else null
// Refined Collapsed Logic
builder.setContentTitle("Auto-Open: $currentStatusText")
if (isWorking) {
builder.setContentText("Opened: $processedETA: ${eta ?: "..."}")
builder.setSubText("$progressPercent% • $remaining Queued")
builder.setContentText("Opened: $processedQueue: $remaining")
builder.setSubText("$progressPercent% • Ends in: ${eta ?: "..."}")
builder.setProgress(sessionTotal, processed, false)
} else {
// Idle stats for collapsed view
builder.setContentText("$processed Opened Today │ $total Lifetime")
builder.setSubText("Monitoring Snaps...")
builder.setContentText("$processed Opened Today │ $total Total")
builder.setSubText(null)
builder.setProgress(0, 0, false)
}
builder.setProgress(if (isWorking) sessionTotal else 0, if (isWorking) processed else 0, !isWorking)
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 (!isCompact) {
val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) }
val bigTextStyle = Notification.BigTextStyle()
val bigTextStyle = Notification.BigTextStyle().setSummaryText("")
val detailText = buildString {
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: $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 PREVIEW\n")
if (isWorking) {
recentSnaps.reversed().forEach { item ->
append("${item.senderName}${item.conversationType} (${item.contentType})\n")
if (config.showQueuePreview.get()) {
append("\n\nQUEUE PREVIEW\n")
if (isWorking) {
recentSnaps.reversed().forEach { item ->
append("${item.senderName}${item.conversationType} (${item.contentType})\n")
}
} else {
append("Monitoring snaps in background...")
}
} else {
append(context.translation["auto_open_snaps.notification_no_snaps_queue"] ?: "Monitoring snaps in background...")
}
}
bigTextStyle.bigText(detailText)
bigTextStyle.setSummaryText(null)
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 shutdownFeature() {
notificationManager.cancel(STATUS_NOTIFICATION_ID)
val finalCount = totalProcessed.get()
if (hasBeenActive.get()) {
val elapsedMillis = System.currentTimeMillis() - sessionStartTime.get() - totalPausedDuration.get()
val durationMins = maxOf(0, elapsedMillis / 60000)
val summary = Notification.Builder(context.androidContext, "auto_open_snaps")
.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)
hasBeenActive.set(false)
cancelStatusNotification(); releaseWakeLock(); hasBeenActive.set(false); triggerLazySave()
}
private fun startWakeLockCooldown() {
wakeLockCooldownJob?.cancel()
wakeLockCooldownJob = context.coroutineScope.launch {
delay(30000)
releaseWakeLock()
}
triggerLazySave()
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(1000)
}
while (needsSaving.get()) { needsSaving.set(false); saveToDiskInternal(); delay(300000) }
isSaving.set(false)
}
}
@@ -591,62 +507,39 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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))
}
synchronized(queuedSnaps) { putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps)) }
}
}
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))
totalDetected.set(prefs.getInt(PREF_TOTAL_DETECTED, 0))
sessionStartTime.set(prefs.getLong(PREF_SESSION_START, System.currentTimeMillis()))
sessionStartTime.set(savedStartTime)
val savedQueueJson = prefs.getString(PREF_SAVED_QUEUE, null)
if (!savedQueueJson.isNullOrBlank()) {
try {
val type = object : TypeToken<List<SnapQueueItem>>() {}.type
val restored: List<SnapQueueItem> = gson.fromJson(savedQueueJson, type)
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()
}
val restored: List<SnapQueueItem> = gson.fromJson(savedQueueJson, object : TypeToken<List<SnapQueueItem>>() {}.type)
synchronized(queuedSnaps) { queuedSnaps.addAll(restored.filter { (now - it.timestamp) < 3600000 }) }
} catch (e: Exception) { prefs.edit().remove(PREF_SAVED_QUEUE).apply() }
}
}
private fun resetPersistence() {
prefs.edit().remove(PREF_TOTAL_OPENED).remove(PREF_TOTAL_DETECTED).remove(PREF_SESSION_START).remove(PREF_SAVED_QUEUE).apply()
synchronized(queuedSnaps) { queuedSnaps.clear() }
}
private fun isInsideSleepWindow(): Boolean {
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 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 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)
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 isGaming(): Boolean {
val am = context.androidContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return am.runningAppProcesses?.firstOrNull { it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }?.processName?.let {
!it.contains("snapchat") && !it.contains("purrfectsnap")
} ?: 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) {
@@ -659,42 +552,35 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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")
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
if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null
}
private fun createPendingIntent(action: String): PendingIntent {
val intent = Intent(action).apply { setPackage(context.androidContext.packageName) }
return PendingIntent.getBroadcast(context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
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 channel = NotificationChannel("auto_open_snaps", "Auto Open Snaps", NotificationManager.IMPORTANCE_LOW).apply {
enableVibration(false); setSound(null, null)
}
notificationManager.createNotificationChannel(channel)
val c = NotificationChannel("auto_open_snaps", "Auto Open Snaps", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) }
notificationManager.createNotificationChannel(c)
}
private fun getSenderDisplayName(senderId: String): String = nameCache.getOrPut(senderId) {
context.database.getFriendInfo(senderId)?.let { it.displayName ?: it.mutableUsername } ?: "Unknown"
}
private fun getSenderDisplayName(id: String): String = metadataCache.getOrPut(id) { context.database.getFriendInfo(id)?.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(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 -> "Snap"
else -> context.translation["auto_open_snaps.content_type_snap"] ?: "Snap"
}
}

View File

@@ -0,0 +1,254 @@
package me.eternal.purrfectsnap.core.features.impl.global
import android.os.SystemClock
import android.view.View
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.ui.dispatchSyntheticTap
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
import java.util.ArrayList
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class AdBlockFix : Feature("AdBlockFix") {
private val adConversationIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
@Volatile
private var lastAutoSkippedOperaFingerprint: String? = null
@Volatile
private var lastAutoSkippedOperaAt = 0L
override fun init() {
if (!context.config.global.blockAds.get()) return
hookFeedEntryTracking()
hookMessagingFeedCallbacks()
hookChatFeedRowSuppression()
hookOperaAutoSkip()
}
private fun hookFeedEntryTracking() {
findClass("com.snapchat.client.messaging.FeedEntry").hookConstructor(HookStage.AFTER) { param ->
val feedEntry = param.thisObject<Any>()
val conversationId = feedEntry.getObjectFieldOrNull("mConversationId")?.let(::SnapUUID)?.toString()
?: return@hookConstructor
if (isCampaignFeedEntry(feedEntry) || isChatAdShareFeedEntry(feedEntry)) {
adConversationIds.add(conversationId)
}
}
}
private fun hookMessagingFeedCallbacks() {
context.mappings.useMapper(CallbackMapper::class) {
classLoader = context.androidContext.classLoader
val callbackMap = callbacks.getAsMap().orEmpty()
val hookedCallbacks = mutableSetOf<String>()
fun hookOnce(
callbackClassName: String,
methodName: String,
block: (param: me.eternal.purrfectsnap.core.util.hook.HookAdapter) -> Unit
) {
val hookKey = "$callbackClassName#$methodName"
if (!hookedCallbacks.add(hookKey)) return
runCatching {
findClass(callbackClassName).hook(methodName, HookStage.BEFORE) { param ->
block(param)
}
}.onFailure {
context.log.warn("Failed to hook $methodName on $callbackClassName")
}
}
callbackMap.entries.forEach { (callbackName, callbackClassName) ->
val className = callbackClassName ?: return@forEach
when {
callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> {
hookOnce(className, "onFetchAndSyncFeedComplete") { param ->
val deletedEntries = param.argNullable<ArrayList<Any>>(2)
filterCampaignFeed(param.arg(0), deletedEntries)
if (deletedEntries?.isNotEmpty() == true) {
param.setArg(4, true)
}
}
}
callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> {
hookOnce(className, "onSyncFeedComplete") { param ->
filterCampaignFeed(param.arg(0), param.argNullable(2))
}
}
callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> {
hookOnce(className, "onFetchFeedComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> {
hookOnce(className, "onFetchFeedEntriesComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> {
hookOnce(className, "onQueryFeedComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "FeedManagerDelegate" -> {
hookOnce(className, "onFeedEntriesUpdated") { param ->
filterCampaignFeed(param.arg(0))
}
hookOnce(className, "onInternalSyncFeed") { param ->
filterCampaignFeed(param.arg(0))
}
}
}
}
}
}
private fun hookChatFeedRowSuppression() {
context.event.subscribe(BindViewEvent::class) { event ->
val modelDump = event.prevModel.toString()
event.friendFeedItem { conversationId ->
if (adConversationIds.contains(conversationId) || isChatAdShareModel(modelDump)) {
hideBoundChatFeedRow(event.view)
}
}
}
}
private fun hideBoundChatFeedRow(view: View) {
view.hideViewCompletely()
(view.parent as? View)?.hideViewCompletely()
(view.parent?.parent as? View)?.hideViewCompletely()
}
private fun hookOperaAutoSkip() {
onNextActivityCreate {
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
val resolvedMethod = methodName.get() ?: return@forEach
classReference.get()?.hook(resolvedMethod, HookStage.AFTER) { param ->
val viewState = runCatching {
param.thisObject<Any>().getObjectField(viewStateField.get()!!)?.toString()
}.getOrNull() ?: return@hook
if (viewState != "FULLY_DISPLAYED") return@hook
val layerList = runCatching {
param.thisObject<Any>().getObjectField(layerListField.get()!!) as? ArrayList<*>
}.getOrNull() ?: return@hook
val paramMap = runCatching {
layerList.map { Layer(it).paramMap }.firstOrNull()
}.getOrNull() ?: return@hook
if (!isSpotlightCommercialPage(paramMap)) return@hook
val fingerprint = buildOperaFingerprint(paramMap)
val now = SystemClock.elapsedRealtime()
if (fingerprint == lastAutoSkippedOperaFingerprint && now - lastAutoSkippedOperaAt < 1_500L) {
return@hook
}
lastAutoSkippedOperaFingerprint = fingerprint
lastAutoSkippedOperaAt = now
runOnUiThread {
context.mainActivity?.window?.decorView?.postDelayed({
context.mainActivity?.window?.decorView?.let { decorView ->
val x = decorView.width * 0.88f
val y = decorView.height * 0.5f
decorView.dispatchSyntheticTap(x, y)
}
}, 70L)
}
}
}
}
}
}
private fun filterCampaignFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) {
entries.removeIf { feedEntry ->
if (!isCampaignFeedEntry(feedEntry)) return@removeIf false
val conversationIdInstance = feedEntry.getObjectFieldOrNull("mConversationId") ?: return@removeIf true
deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance))
true
}
}
private fun createDeletedFeedEntry(conversationIdInstance: Any) =
findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
from("mFeedEntryIdentifier") {
set("mConversationId", conversationIdInstance)
}
set("mReason", "AD_CAMPAIGN_COMPLETE")
}!!
private fun isCampaignFeedEntry(feedEntry: Any?): Boolean {
if (feedEntry == null) return false
if (feedEntry.getObjectFieldOrNull("mConversationSubType")?.toString() == "CAMPAIGN") {
return true
}
return feedEntry.getObjectFieldOrNull("mConversationSubTypeMetadata")
?.getObjectFieldOrNull("mCampaignMetadata") != null
}
private fun isChatAdShareFeedEntry(feedEntry: Any): Boolean {
val interactionDump = feedEntry.getObjectFieldOrNull("mInteractionInfo")?.toString().orEmpty()
val displayDump = feedEntry.getObjectFieldOrNull("mDisplayInfo")?.toString().orEmpty()
val combined = "$interactionDump $displayDump"
return isChatAdShareModel(combined)
}
private fun isChatAdShareModel(modelDump: String): Boolean {
if (modelDump.isBlank()) return false
return modelDump.contains("CHAT_AD_SHARE") ||
modelDump.contains("AD_SHARE") ||
modelDump.contains("ChatAd") ||
modelDump.contains("chat_ad_share") ||
modelDump.contains("chat_sponsored_snap") ||
modelDump.contains("CommonAttachmentViewModel") ||
modelDump.contains("visibilityFeedbackURL") ||
modelDump.contains("pageLoadPingURL")
}
private fun isSpotlightCommercialPage(paramMap: ParamMap): Boolean {
val snapSource = paramMap["SNAP_SOURCE"]?.toString()
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY") {
return false
}
val adProductType = paramMap["ad_product_type"]?.toString()
if (!adProductType.isNullOrBlank() && adProductType != "UNKNOWN" && adProductType != "null") {
return true
}
val pageDump = paramMap.toString().uppercase()
return pageDump.contains("COMMERCIAL") || pageDump.contains("PROMOTED_STORY")
}
private fun buildOperaFingerprint(paramMap: ParamMap): String {
val storyId = paramMap["STORY_ID"]?.toString()
val snapId = paramMap["SNAP_ID"]?.toString()
?: paramMap["snap_id"]?.toString()
val index = paramMap["snap_index_in_story"]?.toString()
?: paramMap["SNAP_POSITION_IN_STORY"]?.toString()
return listOfNotNull(storyId, snapId, index, paramMap["ad_product_type"]?.toString()).joinToString("|")
}
}

View File

@@ -12,6 +12,7 @@ import android.hardware.camera2.CameraCharacteristics.Key
import android.hardware.camera2.CameraManager
import android.media.Image
import android.media.ImageReader
import android.os.Build
import android.util.Range
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -28,6 +29,9 @@ class CameraTweaks : Feature("Camera Tweaks") {
@SuppressLint("MissingPermission", "DiscouragedApi")
override fun init() {
val config = context.config.camera
val skipUnstableStillCaptureTweaks = Build.MANUFACTURER.equals("samsung", ignoreCase = true) ||
Build.HARDWARE.contains("exynos", ignoreCase = true) ||
Build.BRAND.equals("samsung", ignoreCase = true)
// Toggle A: Audio & Video Optimizations (Bitrates)
if (config.audioVideoOptimizations.get()) {
@@ -44,7 +48,7 @@ class CameraTweaks : Feature("Camera Tweaks") {
}
// Toggle B: Camera Optimizations (Hardware ISP - UNSTABLE)
if (config.cameraOptimizations.get()) {
if (config.cameraOptimizations.get() && !skipUnstableStillCaptureTweaks) {
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
val key = param.arg<CaptureRequest.Key<*>>(0)
when (key) {

View File

@@ -3,37 +3,55 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.animation.ValueAnimator
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.database.sqlite.SQLiteDatabase
import android.hardware.camera2.CaptureRequest
import android.media.MediaRecorder
import android.os.Build
import android.transition.Transition
import android.os.HandlerThread
import android.os.Process
import android.util.Base64
import android.util.Range
import android.view.View
import android.view.ViewPropertyAnimator
import android.view.WindowManager
import android.view.animation.Animation
import android.widget.OverScroller
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import java.io.File
import java.lang.Thread
import java.lang.reflect.Method
import java.util.LinkedHashMap
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.ThreadPoolExecutor
import com.google.gson.reflect.TypeToken
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import okhttp3.Dispatcher
class PerformanceMode : Feature("Performance Mode") {
companion object {
private const val CHAT_FEED_CACHE_MAX_ROWS = 400
private const val CHAT_FEED_CACHE_MAX_BLOB_BYTES = 512
private const val CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS = 15_000L
private const val MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS = 64
private const val MESSAGE_WINDOW_STATE_MAX_AGE_MS = 7L * 24L * 60L * 60L * 1000L
private const val SNAP_PREFETCH_GROUP_MESSAGES = 48
private const val SNAP_PREFETCH_DM_MESSAGES = 24
private const val REOPEN_WARMUP_GROUP_MESSAGES = 160
private const val REOPEN_WARMUP_DM_MESSAGES = 96
}
private data class SnapshotCell(
val type: Int,
val stringValue: String? = null,
@@ -47,6 +65,15 @@ class PerformanceMode : Feature("Performance Mode") {
val rows: List<List<SnapshotCell>>,
)
private data class MessageWindowState(
val conversationId: String,
val currentSize: Int,
val oldestOrderKey: Long?,
val newestOrderKey: Long?,
val updatedAt: Long,
val isGroup: Boolean,
)
override fun init() {
val profile = context.config.global.performanceMode.profile.getNullable() ?: return
val isMaxProfile = profile == "max"
@@ -56,6 +83,7 @@ class PerformanceMode : Feature("Performance Mode") {
Process.THREAD_PRIORITY_MORE_FAVORABLE
}
val minimumFrameRate = if (isMaxProfile) 60 else 45
val minimumRecordingFrameRate = if (isMaxProfile) 30 else 24
val durationScale = if (isMaxProfile) 0.35f else 0.55f
val recyclerViewCacheSize = if (isMaxProfile) 64 else 32
val maxRequests = if (isMaxProfile) 192 else 96
@@ -63,11 +91,16 @@ class PerformanceMode : Feature("Performance Mode") {
val minimumCoreThreads = if (isMaxProfile) 16 else 8
val prefetchItemCount = if (isMaxProfile) 24 else 12
val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L
val maxScrollDurationMs = if (isMaxProfile) 120 else 180
val maxScrollDurationMs = if (isMaxProfile) 72 else 180
val preferredRefreshRate = if (isMaxProfile) 120f else 90f
val snapMapTransitionDurationMs = if (isMaxProfile) 0L else 24L
val snapMapCameraDurationMs = if (isMaxProfile) 16L else 64L
val snapMapMoveDurationMs = if (isMaxProfile) 8L else 40L
val snapMapPrefetchZoomDelta = if (isMaxProfile) 6 else 3
val preferredJavaThreadPriority = if (isMaxProfile) Thread.NORM_PRIORITY + 2 else Thread.NORM_PRIORITY + 1
context.log.info(
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate",
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, minRecordingFps=$minimumRecordingFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate, snapMapTransitionMs=$snapMapTransitionDurationMs, snapMapCameraMs=$snapMapCameraDurationMs, snapMapMoveMs=$snapMapMoveDurationMs, snapMapPrefetchZoomDelta=$snapMapPrefetchZoomDelta, javaThreadPriority=$preferredJavaThreadPriority",
"PerformanceMode"
)
@@ -91,38 +124,58 @@ class PerformanceMode : Feature("Performance Mode") {
val executorLog = firstHitLogger("ThreadPoolExecutor.constructor")
val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor")
val animatorLog = firstHitLogger("ValueAnimator.getDurationScale")
val animatorDurationLog = firstHitLogger("ValueAnimator.setDuration")
val viewAnimatorDurationLog = firstHitLogger("ViewPropertyAnimator.setDuration")
val transitionDurationLog = firstHitLogger("Transition.setDuration")
val animationDurationLog = firstHitLogger("Animation.setDuration")
val recyclerCtorLog = firstHitLogger("RecyclerView.constructor")
val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter")
val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager")
val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase")
val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase")
val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate")
val captureRequestLog = firstHitLogger("CaptureRequest.Builder.set")
val sustainedModeLog = firstHitLogger("Window.setSustainedPerformanceMode")
val refreshRateLog = firstHitLogger("Activity.preferredRefreshRate")
val overScrollerLog = firstHitLogger("OverScroller.startScroll")
val chatFeedCacheServeLog = firstHitLogger("ChatFeed.cacheServe")
val chatFeedCacheRefreshLog = firstHitLogger("ChatFeed.cacheRefresh")
val mapDialogLog = firstHitLogger("Dialog.show")
val mapViewLog = firstHitLogger("MapView.constructor")
val mapboxNetworkBlockLog = firstHitLogger("SnapMap.telemetryBlock")
val mapCameraAnimLog = firstHitLogger("SnapMap.mapAnimatorDuration")
val mapThreadLog = firstHitLogger("SnapMap.mapThread")
val mapRendererFpsLog = firstHitLogger("SnapMap.mapRendererFps")
val mapTransitionLog = firstHitLogger("SnapMap.transitionOptions")
val mapMoveLog = firstHitLogger("SnapMap.moveDuration")
fun isPerformanceSensitiveThread(name: String?): Boolean {
val normalizedName = name?.lowercase() ?: return false
return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
return listOf("codec", "transcod", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
normalizedName.contains(it)
}
}
fun clampPositiveDuration(durationMs: Long, maxDurationMs: Long): Long {
if (durationMs <= 0L) return durationMs
return durationMs.coerceAtMost(maxDurationMs)
}
val performanceCacheDir = File(context.androidContext.filesDir, "performance_mode_cache").apply { mkdirs() }
val chatFeedSnapshotFile = File(performanceCacheDir, "chat_feed_snapshot.json")
val lastChatFeedSnapshotWrite = AtomicLong(0L)
val chatFeedSnapshotServedThisProcess = AtomicBoolean(false)
val windowStatePrefs = context.androidContext.getSharedPreferences("purrfectsnap_perf_message_windows", Context.MODE_PRIVATE)
val messageWindowStates = runCatching {
val raw = windowStatePrefs.getString("states", null).orEmpty()
if (raw.isBlank()) {
LinkedHashMap<String, MessageWindowState>()
} else {
context.gson.fromJson<LinkedHashMap<String, MessageWindowState>>(
raw,
object : TypeToken<LinkedHashMap<String, MessageWindowState>>() {}.type
) ?: LinkedHashMap()
}
}.getOrElse { LinkedHashMap() }
fun persistMessageWindowStates() {
runCatching {
windowStatePrefs.edit().putString("states", context.gson.toJson(messageWindowStates)).apply()
}.onFailure {
context.log.error("Failed to persist message window states", it, "PerformanceMode")
}
}
fun isChatFeedQuery(sql: String): Boolean {
val normalized = sql.uppercase()
@@ -144,7 +197,9 @@ class PerformanceMode : Feature("Performance Mode") {
Cursor.FIELD_TYPE_STRING -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
Cursor.FIELD_TYPE_BLOB -> SnapshotCell(
Cursor.FIELD_TYPE_BLOB,
blobValue = Base64.encodeToString(cursor.getBlob(index), Base64.NO_WRAP)
blobValue = cursor.getBlob(index)
?.takeIf { it.size <= CHAT_FEED_CACHE_MAX_BLOB_BYTES }
?.let { Base64.encodeToString(it, Base64.NO_WRAP) }
)
else -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
}
@@ -154,9 +209,11 @@ class PerformanceMode : Feature("Performance Mode") {
val columns = cursor.columnNames.toList()
val rows = mutableListOf<List<SnapshotCell>>()
if (cursor.moveToFirst()) {
var rowCount = 0
do {
rows += columns.indices.map { index -> cursorCell(cursor, index) }
} while (cursor.moveToNext())
rowCount++
} while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext())
}
return CursorSnapshot(columns, rows)
}
@@ -201,12 +258,6 @@ class PerformanceMode : Feature("Performance Mode") {
context.log.info("Invalidated chat feed snapshot after friends mutation sync", "PerformanceMode")
}
}
if (url.contains("messaging") || url.contains("conversation") || url.contains("feed")) {
if (chatFeedSnapshotFile.exists()) {
chatFeedSnapshotFile.delete()
context.log.info("Invalidated chat feed snapshot after messaging/feed network activity", "PerformanceMode")
}
}
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
event.canceled = true
mapboxNetworkBlockLog("url=$url")
@@ -237,7 +288,7 @@ class PerformanceMode : Feature("Performance Mode") {
val thread = param.thisObject<Thread>()
if (!isPerformanceSensitiveThread(thread.name)) return@hook
runCatching {
thread.priority = Thread.MAX_PRIORITY
thread.priority = preferredJavaThreadPriority
}
threadStartLog("name=${thread.name} priority=${thread.priority}")
if ((thread.name ?: "").contains("map", ignoreCase = true) || (thread.name ?: "").contains("mapbox", ignoreCase = true)) {
@@ -272,51 +323,10 @@ class PerformanceMode : Feature("Performance Mode") {
animatorLog("durationScale=$durationScale")
}
ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<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
}
@@ -326,7 +336,6 @@ class PerformanceMode : Feature("Performance Mode") {
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
}
@@ -346,7 +355,6 @@ class PerformanceMode : Feature("Performance Mode") {
layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
}
}
recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount")
}
@@ -375,8 +383,11 @@ class PerformanceMode : Feature("Performance Mode") {
MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param ->
val currentRate = param.arg<Int>(0)
if (currentRate < minimumFrameRate) {
param.setArg(0, minimumFrameRate)
val applied = currentRate
.coerceAtLeast(minimumRecordingFrameRate)
.coerceAtMost(if (isMaxProfile) 60 else 45)
if (applied != currentRate) {
param.setArg(0, applied)
}
mediaRecorderLog("requested=$currentRate applied=${param.arg<Int>(0)}")
}
@@ -401,27 +412,9 @@ class PerformanceMode : Feature("Performance Mode") {
}
}
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}")
}
activity.window.setWindowAnimations(0)
}
}
@@ -434,10 +427,6 @@ class PerformanceMode : Feature("Performance Mode") {
val window = dialog.window ?: return@hook
runCatching {
window.setWindowAnimations(0)
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
window.attributes = window.attributes.apply {
flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
}
if (dialog::class.java.name.contains("map", ignoreCase = true) || dialog::class.java.name.contains("snap", ignoreCase = true)) {
mapDialogLog("class=${dialog::class.java.name}")
}
@@ -447,7 +436,6 @@ class PerformanceMode : Feature("Performance Mode") {
runCatching {
findClass("com.mapbox.mapboxsdk.maps.MapView").hookConstructor(HookStage.AFTER) { param ->
val mapView = param.nullableThisObject<Any>() as? View ?: return@hookConstructor
mapView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
mapView.overScrollMode = View.OVER_SCROLL_NEVER
mapViewLog("class=${mapView::class.java.name}")
}
@@ -464,14 +452,165 @@ class PerformanceMode : Feature("Performance Mode") {
}
}
runCatching {
val nativeMapViewClass = findClass("com.mapbox.mapboxsdk.maps.NativeMapView")
val transitionOptionsClass = findClass("com.mapbox.mapboxsdk.style.layers.TransitionOptions")
val transitionOptionsCtor = transitionOptionsClass.getDeclaredConstructor(Long::class.javaPrimitiveType, Long::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).apply {
isAccessible = true
}
fun findNativeMapMethod(name: String, predicate: (Method) -> Boolean): Method? {
return nativeMapViewClass.findRestrictedMethod { method ->
method.name == name && predicate(method)
}?.apply {
isAccessible = true
}
}
val nativeCancelTransitions = findNativeMapMethod("nativeCancelTransitions") { it.parameterCount == 0 }
val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType }
val nativeSetPrefetchZoomDelta = findNativeMapMethod("nativeSetPrefetchZoomDelta") { it.parameterCount == 1 && it.parameterTypes[0] == Int::class.javaPrimitiveType }
val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionDuration = findNativeMapMethod("nativeSetTransitionDuration") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionOptions = findNativeMapMethod("nativeSetTransitionOptions") { it.parameterCount == 1 && it.parameterTypes[0].name == transitionOptionsClass.name }
nativeMapViewClass.hookConstructor(HookStage.AFTER) { param ->
val nativeMapView = param.thisObject<Any>()
runCatching {
nativeSetPrefetchTiles?.invoke(nativeMapView, true)
nativeSetPrefetchZoomDelta?.invoke(nativeMapView, snapMapPrefetchZoomDelta)
nativeSetTransitionDelay?.invoke(nativeMapView, 0L)
nativeSetTransitionDuration?.invoke(nativeMapView, snapMapTransitionDurationMs)
nativeSetTransitionOptions?.invoke(
nativeMapView,
transitionOptionsCtor.newInstance(snapMapTransitionDurationMs, 0L, false)
)
nativeCancelTransitions?.invoke(nativeMapView)
mapTransitionLog("transitionMs=$snapMapTransitionDurationMs prefetchZoomDelta=$snapMapPrefetchZoomDelta placementTransitions=false")
}
}
nativeMapViewClass.findRestrictedMethod { method ->
method.name == "g" &&
method.parameterCount == 6 &&
method.parameterTypes.last() == Long::class.javaPrimitiveType
}?.hook(HookStage.BEFORE) { param ->
val original = param.arg<Long>(5)
val applied = clampPositiveDuration(original, snapMapCameraDurationMs)
if (applied != original) {
param.setArg(5, applied)
}
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
mapCameraAnimLog("requested=$original applied=${param.arg<Long>(5)}")
}
nativeMapViewClass.findRestrictedMethod { method ->
method.name == "v" &&
method.parameterCount == 3 &&
method.parameterTypes[0] == Double::class.javaPrimitiveType &&
method.parameterTypes[1] == Double::class.javaPrimitiveType &&
method.parameterTypes[2] == Long::class.javaPrimitiveType
}?.hook(HookStage.BEFORE) { param ->
val original = param.arg<Long>(2)
val applied = clampPositiveDuration(original, snapMapMoveDurationMs)
if (applied != original) {
param.setArg(2, applied)
}
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
mapMoveLog("requested=$original applied=${param.arg<Long>(2)}")
}
}.onFailure {
context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode")
}
runCatching {
findClass("com.snapchat.client.messaging.MessageWindowManager\$CppProxy").hook("initWindow", HookStage.BEFORE) { param ->
if (!isMaxProfile) return@hook
val conversationId = runCatching {
SnapUUID(param.arg(0)).toString()
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return@hook
val initParams = param.arg<Any>(1)
val conversationType = context.database.getConversationType(conversationId) ?: return@hook
val isGroup = conversationType == 1
val savedState = synchronized(messageWindowStates) {
messageWindowStates[conversationId]
?.takeIf { System.currentTimeMillis() - it.updatedAt <= MESSAGE_WINDOW_STATE_MAX_AGE_MS }
}
val enumConstants = initParams.getObjectField("mStartingType")?.javaClass?.enumConstants ?: return@hook
if (savedState != null) {
val restoredMaxSize = if (savedState.isGroup) {
savedState.currentSize.coerceAtLeast(220).coerceAtMost(520)
} else {
savedState.currentSize.coerceAtLeast(140).coerceAtMost(320)
}
val restoredForward = (savedState.currentSize + if (savedState.isGroup) 24 else 16).coerceAtMost(restoredMaxSize)
val restoredBack = if (savedState.isGroup) 180 else 120
initParams.setObjectField("mStartingType", enumConstants.firstOrNull { it.toString() == "MESSAGE" } ?: return@hook)
initParams.setObjectField("mStartingOrderKey", savedState.oldestOrderKey ?: savedState.newestOrderKey)
initParams.setObjectField("mMaxSize", restoredMaxSize)
initParams.setObjectField("mNumMessagesForward", restoredForward)
initParams.setObjectField("mNumMessagesBack", restoredBack)
val warmupAmount = if (savedState.isGroup) REOPEN_WARMUP_GROUP_MESSAGES else REOPEN_WARMUP_DM_MESSAGES
val oldestKey = savedState.oldestOrderKey
if (oldestKey != null) {
context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated(
conversationId = conversationId,
lastMessageId = oldestKey,
amount = warmupAmount,
onSuccess = {},
onError = {}
)
}
}
}
}.onFailure {
context.log.error("Failed to install saved message window restore hooks", it, "PerformanceMode")
}
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("MessageWindowManagerDelegate")?.hook("onWindowUpdated", HookStage.AFTER) { param ->
if (!isMaxProfile) return@hook
val conversationId = runCatching { SnapUUID(param.arg(0)).toString() }.getOrNull() ?: return@hook
val update = param.arg<Any>(2)
val pagination = update.getObjectField("mPagination") ?: return@hook
val currentSize = pagination.getObjectField("mCurrentSize") as? Int ?: return@hook
val oldestOrderKey = pagination.getObjectField("mOldestOrderKey") as? Long
val newestOrderKey = pagination.getObjectField("mNewestOrderKey") as? Long
val conversationType = context.database.getConversationType(conversationId) ?: 0
val isGroup = conversationType == 1
synchronized(messageWindowStates) {
messageWindowStates[conversationId] = MessageWindowState(
conversationId = conversationId,
currentSize = currentSize.coerceAtMost(if (isGroup) 420 else 260),
oldestOrderKey = oldestOrderKey,
newestOrderKey = newestOrderKey,
updatedAt = System.currentTimeMillis(),
isGroup = isGroup
)
while (messageWindowStates.size > MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS) {
val eldestKey = messageWindowStates.entries.minByOrNull { it.value.updatedAt }?.key ?: break
messageWindowStates.remove(eldestKey)
}
persistMessageWindowStates()
}
}
}
runCatching {
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.BEFORE) { param ->
if (!isMaxProfile) return@hook
val sql = param.argNullable<String>(1) ?: return@hook
if (!isChatFeedQuery(sql)) return@hook
if (chatFeedSnapshotServedThisProcess.get()) return@hook
readSnapshot(chatFeedSnapshotFile)?.let { snapshot ->
param.setResult(snapshotToMatrixCursor(snapshot))
chatFeedCacheServeLog("rows=${snapshot.rows.size} file=${chatFeedSnapshotFile.name}")
chatFeedSnapshotServedThisProcess.set(true)
}
}
@@ -479,15 +618,18 @@ class PerformanceMode : Feature("Performance Mode") {
if (!isMaxProfile) return@hook
val sql = param.argNullable<String>(1) ?: return@hook
if (!isChatFeedQuery(sql)) return@hook
if (chatFeedSnapshotFile.exists()) return@hook
val cursor = param.getResult() as? Cursor ?: return@hook
val now = System.currentTimeMillis()
if (now - lastChatFeedSnapshotWrite.get() < CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS) return@hook
val snapshot = snapshotFromCursor(cursor)
if (snapshot.rows.isEmpty()) return@hook
writeSnapshot(chatFeedSnapshotFile, snapshot)
param.setResult(snapshotToMatrixCursor(snapshot))
runCatching { cursor.close() }
chatFeedCacheRefreshLog("rows=${snapshot.rows.size} file=${chatFeedSnapshotFile.name}")
lastChatFeedSnapshotWrite.set(now)
}
}.onFailure {
context.log.error("Failed to install chat feed cache hooks", it, "PerformanceMode")
}
}
}

View File

@@ -30,6 +30,7 @@ class ConversationManager(
private val fetchConversationWithMessagesMethod by lazy { findMethodByName("fetchConversationWithMessages") }
private val fetchMessageByServerId by lazy { findMethodByName("fetchMessageByServerId") }
private val fetchMessagesByServerIds by lazy { findMethodByName("fetchMessagesByServerIds") }
private val fetchPrefetchableMessagesForConversationsMethod by lazy { findMethodByName("fetchPrefetchableMessagesForConversations") }
private val displayedMessagesMethod by lazy { findMethodByName("displayedMessages") }
private val fetchMessage by lazy { findMethodByName("fetchMessage") }
private val clearConversation by lazy { findMethodByName("clearConversation") }
@@ -163,6 +164,37 @@ class ConversationManager(
)
}
fun fetchPrefetchableMessagesForConversations(
conversationIds: List<String>,
strategyName: String,
messagesPerConversation: Int,
onSuccess: (List<Message>) -> Unit = {},
onError: (error: String) -> Unit = {}
) {
val prefetchRequestClass = fetchPrefetchableMessagesForConversationsMethod.parameterTypes.firstOrNull {
it.name == "com.snapchat.client.messaging.PrefetchRequest"
} ?: error("PrefetchRequest parameter type not found")
val strategyClass = context.androidContext.classLoader.loadClass("com.snapchat.client.messaging.PrefetchStrategy")
val strategy = strategyClass.enumConstants?.firstOrNull { it.toString() == strategyName }
?: error("PrefetchStrategy $strategyName not found")
val prefetchRequest = prefetchRequestClass
.getConstructor(strategyClass, Int::class.javaPrimitiveType)
.newInstance(strategy, messagesPerConversation)
fetchPrefetchableMessagesForConversationsMethod.invoke(
instanceNonNull(),
conversationIds.map { it.toSnapUUID().instanceNonNull() }.toCollection(ArrayList()),
prefetchRequest,
CallbackBuilder(getCallbackClass("FetchMessagesCallback"))
.override("onFetchMessagesComplete") { param ->
onSuccess(param.arg<List<*>>(0).map { Message(it) })
}
.override("onError") {
onError(it.arg<Any>(0).toString())
}.build()
)
}
fun clearConversation(conversationId: String, onSuccess: () -> Unit, onError: (error: String) -> Unit) {
val callback = CallbackBuilder(getCallbackClass("Callback"))
.override("onSuccess") { onSuccess() }

View File

@@ -19,6 +19,14 @@ fun UUID.toBytes(): ByteArray =
class SnapUUID(
private val obj: Any?
) : AbstractWrapper(obj) {
private fun extractUuidBytesFromObject(any: Any): ByteArray? {
runCatching { any.getObjectField("mId") as? ByteArray }.getOrNull()?.let { return it }
runCatching { any.javaClass.getMethod("getId").invoke(any) as? ByteArray }.getOrNull()?.let { return it }
runCatching { any.javaClass.getMethod("getUuid").invoke(any) as? ByteArray }.getOrNull()?.let { return it }
runCatching { any.javaClass.getMethod("uuid").invoke(any) as? ByteArray }.getOrNull()?.let { return it }
return null
}
private val uuidBytes by lazy {
when {
obj is String -> {
@@ -38,6 +46,10 @@ class SnapUUID(
any.getObjectField("mId") as ByteArray
}
}
obj is Any -> {
extractUuidBytesFromObject(obj)
?: runCatching { UUID.fromString(obj.toString()).toBytes() }.getOrElse { ByteArray(16) }
}
else -> ByteArray(16)
}
}

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.6.4
APP_VERSION_CODE=318
APP_VERSION_NAME=1.6.8
APP_VERSION_CODE=324
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c

View File

@@ -5,10 +5,16 @@ declare var _runtimeName: string;
export const runtimeName = _runtimeName;
let remoteImports: any = null;
try {
remoteImports = require(_runtimeName + "_core/DeviceBridge")?.[_getImportsFunctionName]?.();
} catch {
remoteImports = null;
for (const moduleName of ["DeviceBridge", "Device"]) {
try {
const imports = require(_runtimeName + "_core/" + moduleName)?.[_getImportsFunctionName]?.();
if (imports != null) {
remoteImports = imports;
break;
}
} catch {
// Some Snapchat builds expose DeviceBridge, others only Device.
}
}
function callRemoteFunction(method: string, ...args: any[]): any | null {