Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b44558babd | ||
|
|
e97a37d18e | ||
|
|
281d55689a | ||
|
|
7475998961 | ||
|
|
f01b6c2f9a | ||
|
|
98f8d6ebea | ||
|
|
015226cb40 | ||
|
|
ad06b0dffb | ||
|
|
4b4d64e8eb | ||
|
|
3eec22c615 | ||
|
|
9a2e6065dc | ||
|
|
8e00c29a59 | ||
|
|
9ec9517ec5 | ||
|
|
e822fc20b4 | ||
|
|
f3c794fc47 | ||
|
|
6e6d311c23 |
@@ -1 +1 @@
|
||||
- Test
|
||||
- All users are recommended to use the new Performance Mode feature! Go to the features tab and then select global and select performance mode and set it to Max. Then force stop and reopen Snapchat and you will feel the difference i.e. Snapchat will feel a lot faster.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -86,7 +86,7 @@ class AnnouncementCheckWorker(
|
||||
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
val builder = NotificationCompat.Builder(appContext, channelId)
|
||||
.setSmallIcon(R.drawable.launcher_icon_monochrome)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
|
||||
@@ -77,6 +77,7 @@ import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
|
||||
import me.eternal.purrfectsnap.core.features.impl.experiments.RandomizedDeviceProfile
|
||||
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
|
||||
import me.eternal.purrfectsnap.ui.manager.Routes
|
||||
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
|
||||
@@ -188,7 +189,72 @@ class FeaturesRootSection : Routes.Route() {
|
||||
}
|
||||
|
||||
internal fun isRandomizedProfileActionProperty(propertyName: String): Boolean {
|
||||
return propertyName == "generate_fresh_profile_action" || propertyName == "view_current_profile_action"
|
||||
return propertyName == "generate_fresh_profile_action" ||
|
||||
propertyName == "view_current_profile_action" ||
|
||||
propertyName == "backup_profile_action" ||
|
||||
propertyName == "restore_profile_action"
|
||||
}
|
||||
|
||||
internal fun backupRandomizedProfile(onConfigChanged: () -> Unit) {
|
||||
val profileSnapshot = getRandomizedProfileSnapshot()
|
||||
if (profileSnapshot.startsWith("No generated profile")) {
|
||||
context.shortToast(
|
||||
context.translation["manager.dialogs.randomize_device_profile.empty"]
|
||||
?: "No generated profile is available yet. Enable the feature in Snapchat first."
|
||||
)
|
||||
return
|
||||
}
|
||||
activityLauncher {
|
||||
saveFile("randomized-device-profile.json", "application/json") { uri ->
|
||||
runCatching {
|
||||
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.bufferedWriter()?.use {
|
||||
it.write(profileSnapshot)
|
||||
} ?: error("Failed to open backup destination")
|
||||
onConfigChanged()
|
||||
context.shortToast("Randomized profile backup saved")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to back up randomized profile", it)
|
||||
context.shortToast("Failed to back up randomized profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun restoreRandomizedProfile(onConfigChanged: () -> Unit) {
|
||||
activityLauncher {
|
||||
openFile("application/json") { uri ->
|
||||
runCatching {
|
||||
val importedJson = context.androidContext.contentResolver.openInputStream(uri.toUri())
|
||||
?.bufferedReader()
|
||||
?.use { it.readText() }
|
||||
?.trim()
|
||||
?: error("Failed to read randomized profile backup")
|
||||
val profile = RandomizedDeviceProfile.fromJson(importedJson)
|
||||
val generationToken = UUID.randomUUID().toString()
|
||||
context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
|
||||
.edit()
|
||||
.putString("randomized_device_profile", profile.toJson().toString())
|
||||
.putString("randomized_device_profile_token", generationToken)
|
||||
.putString("android_id", profile.androidId)
|
||||
.putString("advertising_id", profile.advertisingId)
|
||||
.putString("bluetooth_address", profile.bluetoothMacAddress)
|
||||
.putString("gsf_id", profile.gsfId)
|
||||
.putString("random_device", profile.deviceInfo.model)
|
||||
.putString("device_fingerprint", profile.buildFingerprint)
|
||||
.apply()
|
||||
|
||||
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
|
||||
randomizeConfig.profileGenerationToken.set(generationToken)
|
||||
randomizeConfig.currentProfileSnapshot.set(profile.toJson().toString(2))
|
||||
context.config.writeConfig()
|
||||
onConfigChanged()
|
||||
context.shortToast("Randomized profile restored. Restart Snapchat to apply it.")
|
||||
}.onFailure {
|
||||
context.log.error("Failed to restore randomized profile", it)
|
||||
context.shortToast("Failed to restore randomized profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToMainRoot() {
|
||||
@@ -664,6 +730,8 @@ class FeaturesRootSection : Routes.Route() {
|
||||
val actionLabel = when (property.name) {
|
||||
"generate_fresh_profile_action" -> context.translation[property.key.propertyName()] ?: "Generate Fresh Profile"
|
||||
"view_current_profile_action" -> context.translation[property.key.propertyName()] ?: "View Current Profile"
|
||||
"backup_profile_action" -> context.translation[property.key.propertyName()] ?: "Backup Profile"
|
||||
"restore_profile_action" -> context.translation[property.key.propertyName()] ?: "Restore Profile"
|
||||
else -> property.name
|
||||
}
|
||||
Button(
|
||||
@@ -685,8 +753,12 @@ class FeaturesRootSection : Routes.Route() {
|
||||
?: "Fresh randomized profile requested. Restart Snapchat to apply it."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else if (property.name == "view_current_profile_action") {
|
||||
showCurrentRandomProfileDialog = true
|
||||
} else if (property.name == "backup_profile_action") {
|
||||
backupRandomizedProfile(onConfigChanged)
|
||||
} else if (property.name == "restore_profile_action") {
|
||||
restoreRandomizedProfile(onConfigChanged)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.2").get())
|
||||
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("314").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.
|
||||
|
||||
@@ -1,3 +1,47 @@
|
||||
## 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!
|
||||
- Fix: Crash issues for some devices after enabling spoof
|
||||
- Fix: Notification Icon for the Announcements(tq to Kaladin)
|
||||
- Fix: Auto Open Engine not processing, stuck on monitor/retry/failed loop causing overheating of the devices(tq to Kaladin)
|
||||
- Fix: Refactor the Notification card for the Auto Open to remove dynamic progress bar to add the native progress bar(tq to Kaladin)
|
||||
- Fix: Auto Open Statistics have been refactored to now show the dynamic queue status(tq to Kaladin)
|
||||
- New: Auto Open Thermal Protection/Throttle toggle, when turned on the processing will be doubled down to reduce the temps of the device to maintain a study temp of 40 C and below(tq to Kaladin)
|
||||
|
||||
## v1.6.4
|
||||
- New: Performance mode feature!(Smooth & Max)
|
||||
- Fix: Failed to init feature Device Spoofer for some devices
|
||||
- Fix: Increase the default queue of auto open snaps
|
||||
- Fix: user_conversation error spam
|
||||
|
||||
## v1.6.3
|
||||
- Fix: Blank Global Settings page
|
||||
- New: Backup & Restore option for Randomized Device Profile feature!
|
||||
|
||||
## v1.6.2
|
||||
- New: Randomized Device Profile Feature!
|
||||
- Show Activation Overlay
|
||||
|
||||
@@ -2057,6 +2057,14 @@
|
||||
"view_current_profile_action": {
|
||||
"name": "View Current Profile",
|
||||
"description": "Inspect the latest randomized profile snapshot"
|
||||
},
|
||||
"backup_profile_action": {
|
||||
"name": "Backup Profile",
|
||||
"description": "Export the full randomized profile as a backup file"
|
||||
},
|
||||
"restore_profile_action": {
|
||||
"name": "Restore Profile",
|
||||
"description": "Import and restore a previously backed up randomized profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
@@ -1648,6 +1649,7 @@
|
||||
"status_monitoring": "Monitoring",
|
||||
"status_active": "Active",
|
||||
"status_paused": "Paused",
|
||||
"thermal_status_title": "Thermal Cooling (Throttled)",
|
||||
"processed_count": "Opened",
|
||||
"queue_size": "Queue",
|
||||
"action_reset": "Reset Statistics",
|
||||
@@ -1682,10 +1684,6 @@
|
||||
"name": "Auto Open Compact Notification",
|
||||
"description": "Use a smaller, single-line notification for status updates"
|
||||
},
|
||||
"show_progress_bar": {
|
||||
"name": "Show Progress Bar",
|
||||
"description": "Display a visual progress bar in the status notification"
|
||||
},
|
||||
"show_lifetime_stats": {
|
||||
"name": "Show Lifetime Statistics",
|
||||
"description": "Include the total number of snaps opened since installation in the notification"
|
||||
@@ -1694,7 +1692,12 @@
|
||||
"name": "Show Queue Preview",
|
||||
"description": "Show a list of the most recent snaps waiting in the queue (Expanded only)"
|
||||
},
|
||||
"thermal_protection": {
|
||||
"name": "Thermal Protection",
|
||||
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40°C to prevent overheating"
|
||||
},
|
||||
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
|
||||
"content_type_snap": "Snap",
|
||||
"only_when_idle": {
|
||||
"name": "Auto Open Schedule",
|
||||
"description": "Configure a specific time window where the engine will throttle its speed."
|
||||
@@ -1703,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",
|
||||
@@ -1909,6 +1916,16 @@
|
||||
"name": "Disable Metrics",
|
||||
"description": "Blocks sending specific analytic data to Snapchat"
|
||||
},
|
||||
"performance_mode": {
|
||||
"name": "Performance Mode",
|
||||
"description": "Applies an app-wide speed profile for navigation, preview loading, background work, and camera responsiveness",
|
||||
"properties": {
|
||||
"performance_profile": {
|
||||
"name": "Performance Profile",
|
||||
"description": "Select how aggressively PurrfectSnap pushes app-wide performance tuning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"disable_story_sections": {
|
||||
"name": "Disable Story Sections",
|
||||
"description": "Removes sections from the Stories page\nMay require a refresh to work properly"
|
||||
@@ -2410,7 +2427,7 @@
|
||||
"name": "Settings Details",
|
||||
"description": "Enable settings spoofing and fine-tune its namespaces",
|
||||
"properties": {
|
||||
"secure": {
|
||||
"secure_settings": {
|
||||
"name": "Secure Settings",
|
||||
"description": "Enable secure settings spoofing and fine-tune secure values",
|
||||
"properties": {
|
||||
@@ -2424,7 +2441,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
"system_settings": {
|
||||
"name": "System Settings",
|
||||
"description": "Enable system settings spoofing and fine-tune system values",
|
||||
"properties": {
|
||||
@@ -2438,7 +2455,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"global_settings": {
|
||||
"name": "Global Settings",
|
||||
"description": "Enable global settings spoofing and fine-tune global values",
|
||||
"properties": {
|
||||
@@ -2565,6 +2582,14 @@
|
||||
"view_current_profile_action": {
|
||||
"name": "View Current Profile",
|
||||
"description": "Inspect the latest randomized profile snapshot"
|
||||
},
|
||||
"backup_profile_action": {
|
||||
"name": "Backup Profile",
|
||||
"description": "Export the full randomized profile as a backup file"
|
||||
},
|
||||
"restore_profile_action": {
|
||||
"name": "Restore Profile",
|
||||
"description": "Import and restore a previously backed up randomized profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3010,7 +3035,8 @@
|
||||
"NOTE": "Audio Note",
|
||||
"SNAP": "Snap",
|
||||
"SAVEABLE_SNAP": "Saveable Snap",
|
||||
"null": "Snapchat Default"
|
||||
"null": "Snapchat Default",
|
||||
"multiple_media_toast": "You can only send one media at a time"
|
||||
},
|
||||
"strip_media_metadata": {
|
||||
"hide_caption_text": "Hide Caption Text",
|
||||
@@ -3057,6 +3083,11 @@
|
||||
"custom_image_upload_format": {
|
||||
"null": "Automatic"
|
||||
},
|
||||
"performance_profile": {
|
||||
"smooth": "Smooth",
|
||||
"max": "Max",
|
||||
"null": "Disabled"
|
||||
},
|
||||
"update_check_frequency": {
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
@@ -3650,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}"
|
||||
@@ -3740,6 +3772,7 @@
|
||||
"paused_message": "Processing paused. Queue preserved ({count} snaps)",
|
||||
"status_paused": "Paused",
|
||||
"status_monitoring": "Monitoring",
|
||||
"thermal_status_title": "Thermal Cooling (Throttled)",
|
||||
"status_active": "Active",
|
||||
"status_failed": "Failed to open {sender}",
|
||||
"status_retrying": "Retrying in background...",
|
||||
@@ -3858,7 +3891,6 @@
|
||||
"material3_strings": {
|
||||
"date_range_picker_start_headline": "From",
|
||||
"date_range_picker_end_headline": "To",
|
||||
"date_range_picker_title": "Select date range",
|
||||
"date_picker_switch_to_calendar_mode": "Calendar",
|
||||
"date_picker_switch_to_input_mode": "Input",
|
||||
"date_range_picker_scroll_to_previous_month": "Previous month",
|
||||
@@ -4342,10 +4374,3 @@
|
||||
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
|
||||
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -38,9 +38,18 @@ class Global : ConfigContainer() {
|
||||
val customUploadImageFormat = unique("custom_image_upload_format", "jpeg", "png", "webp") { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) }
|
||||
}
|
||||
|
||||
inner class PerformanceModeConfig : ConfigContainer() {
|
||||
val profile = unique("performance_profile", "smooth", "max") {
|
||||
requireRestart()
|
||||
}
|
||||
}
|
||||
|
||||
val betterLocation = container("better_location", BetterLocationConfig())
|
||||
val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() }
|
||||
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
|
||||
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply {
|
||||
profile.set("max")
|
||||
}
|
||||
val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "erase_message", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() }
|
||||
val disableMetrics = boolean("disable_metrics") { requireRestart() }
|
||||
val disableStorySections = multiple("disable_story_sections", "friends", "suggested_stories", "following", "discover") { requireRestart(); requireCleanCache() }
|
||||
|
||||
@@ -166,7 +166,7 @@ class MessagingTweaks : ConfigContainer() {
|
||||
val maxDelayMs = integer("max_delay_ms", defaultValue = 100) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && it.toInt() > minDelay.get() }
|
||||
}
|
||||
val queueSize = integer("queue_size", defaultValue = 10) {
|
||||
val queueSize = integer("queue_size", defaultValue = 1000) {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
|
||||
}
|
||||
val retryAttempts = integer("retry_attempts", defaultValue = 5) {
|
||||
@@ -176,13 +176,11 @@ class MessagingTweaks : ConfigContainer() {
|
||||
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
|
||||
}
|
||||
val compactNotification = boolean("compact_notification", false)
|
||||
val showProgressBar = boolean("show_progress_bar", true)
|
||||
val showLifetimeStats = boolean("show_lifetime_stats", false)
|
||||
val showQueuePreview = boolean("show_queue_preview", true)
|
||||
val thermalProtection = boolean("thermal_protection", false)
|
||||
|
||||
// Resource Intelligence: Smart triggers for battery and data safety
|
||||
val onlyOnWifi = boolean("only_on_wifi", false)
|
||||
val 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() }
|
||||
}
|
||||
|
||||
@@ -115,9 +115,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
}
|
||||
|
||||
inner class RandomizedSettingsConfig : ConfigContainer(hasGlobalState = true) {
|
||||
val secure = container("secure", RandomizedSecureSettingsConfig().apply { globalState = true })
|
||||
val system = container("system", RandomizedSystemSettingsConfig().apply { globalState = true })
|
||||
val global = container("global", RandomizedGlobalSettingsConfig().apply { globalState = true })
|
||||
val secureSettings = container("secure_settings", RandomizedSecureSettingsConfig().apply { globalState = true })
|
||||
val systemSettings = container("system_settings", RandomizedSystemSettingsConfig().apply { globalState = true })
|
||||
val globalSettings = container("global_settings", RandomizedGlobalSettingsConfig().apply { globalState = true })
|
||||
}
|
||||
|
||||
inner class RandomizedWifiConfig : ConfigContainer(hasGlobalState = true) {
|
||||
@@ -178,6 +178,8 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
|
||||
}
|
||||
val generateFreshProfileAction = string("generate_fresh_profile_action")
|
||||
val viewCurrentProfileAction = string("view_current_profile_action")
|
||||
val backupProfileAction = string("backup_profile_action")
|
||||
val restoreProfileAction = string("restore_profile_action")
|
||||
val profileGenerationToken = string("profile_generation_token") {
|
||||
addFlags(ConfigFlag.HIDDEN)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ class DatabaseAccess(
|
||||
} == true
|
||||
}
|
||||
|
||||
private val hasArroyoUserConversationTable by lazy {
|
||||
useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_conversation'")?.use { query ->
|
||||
query.moveToFirst() && query.getStringOrNull("name") == "user_conversation"
|
||||
}
|
||||
} == true
|
||||
}
|
||||
|
||||
private fun useDatabase(database: DatabaseType, writeMode: Boolean = false): SQLiteDatabase? {
|
||||
// only cache read-only databases
|
||||
if (!writeMode && openedDatabases.containsKey(database) && openedDatabases[database]?.isOpen == true) {
|
||||
@@ -148,6 +156,10 @@ class DatabaseAccess(
|
||||
}?.toMutableMap() ?: mutableMapOf()
|
||||
}
|
||||
|
||||
if (!hasArroyoUserConversationTable) {
|
||||
return@lazy mutableMapOf()
|
||||
}
|
||||
|
||||
(useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery(
|
||||
"SELECT client_conversation_id, conversation_type, user_id FROM user_conversation WHERE user_id != ?",
|
||||
@@ -354,7 +366,7 @@ class DatabaseAccess(
|
||||
}
|
||||
|
||||
fun getConversationType(conversationId: String): Int? {
|
||||
if (hasArroyoConversationTable) {
|
||||
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
|
||||
return getFeedEntryByConversationId(conversationId)?.conversationType
|
||||
}
|
||||
|
||||
@@ -372,7 +384,9 @@ class DatabaseAccess(
|
||||
}
|
||||
|
||||
fun getDMConversationId(userId: String): String? {
|
||||
if (hasArroyoConversationTable) {
|
||||
friendDMsCache[userId]?.let { return it }
|
||||
|
||||
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
|
||||
return friendDMsCache[userId]
|
||||
}
|
||||
|
||||
@@ -408,6 +422,10 @@ class DatabaseAccess(
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasArroyoUserConversationTable) {
|
||||
return getFeedEntryByConversationId(conversationId)?.participants
|
||||
}
|
||||
|
||||
return useDatabase(DatabaseType.ARROYO)?.performOperation {
|
||||
safeRawQuery(
|
||||
"SELECT user_id FROM user_conversation WHERE client_conversation_id = ?",
|
||||
|
||||
@@ -80,6 +80,7 @@ class FeatureManager(
|
||||
MessageLogger(),
|
||||
ConvertMessageLocally(),
|
||||
SnapchatPlus(),
|
||||
AdBlockFix(),
|
||||
DisableMetrics(),
|
||||
EndpointsBlocker(),
|
||||
PreventMessageSending(),
|
||||
@@ -99,6 +100,7 @@ class FeatureManager(
|
||||
MeoPasscodeBypass(),
|
||||
AppLock(),
|
||||
CameraTweaks(),
|
||||
PerformanceMode(),
|
||||
InfiniteStoryBoost(),
|
||||
PinConversations(),
|
||||
DeviceSpooferHook(),
|
||||
|
||||
@@ -2,9 +2,11 @@ package me.eternal.purrfectsnap.core.features
|
||||
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.common.data.RuleState
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType) : Feature(name) {
|
||||
private val listeners = mutableListOf<(String, Boolean) -> Unit>()
|
||||
private val ruleCache = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
fun addStateListener(listener: (conversationId: String, newState: Boolean) -> Unit) {
|
||||
listeners.add(listener)
|
||||
@@ -13,18 +15,22 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
|
||||
open fun getRuleState() = context.config.rules.getRuleState(ruleType)
|
||||
|
||||
fun setState(conversationId: String, state: Boolean) {
|
||||
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
context.bridgeClient.setRule(
|
||||
context.database.getDMOtherParticipant(conversationId) ?: conversationId,
|
||||
targetId,
|
||||
ruleType,
|
||||
state
|
||||
)
|
||||
ruleCache[targetId] = state
|
||||
listeners.forEach { it(conversationId, state) }
|
||||
}
|
||||
|
||||
fun getState(conversationId: String) =
|
||||
context.bridgeClient.getRules(
|
||||
context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
).contains(ruleType) && getRuleState() != null
|
||||
fun getState(conversationId: String): Boolean {
|
||||
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
|
||||
return ruleCache.getOrPut(targetId) {
|
||||
context.bridgeClient.getRules(targetId).contains(ruleType)
|
||||
} && getRuleState() != null
|
||||
}
|
||||
|
||||
fun canUseRule(conversationId: String): Boolean {
|
||||
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
|
||||
|
||||
@@ -37,18 +37,39 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}.getOrNull()
|
||||
|
||||
val propertyOverrides = mutableMapOf<String, ConfigFilter>()
|
||||
val loggedOverrides = mutableSetOf<String>()
|
||||
|
||||
fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: (ConfigKeyInfo) -> Any?, isAppExperiment: Boolean = false) {
|
||||
propertyOverrides[key] = ConfigFilter(filter, value, isAppExperiment)
|
||||
}
|
||||
|
||||
fun logPerformanceOverride(key: String, value: Any?) {
|
||||
if (!key.contains("PRELOAD") &&
|
||||
!key.contains("PERFORMANCE") &&
|
||||
!key.contains("WARM") &&
|
||||
!key.contains("PREFETCH") &&
|
||||
!key.contains("LATENCY") &&
|
||||
!key.contains("ANALYTICS") &&
|
||||
!key.contains("THREAD_PRIORITY") &&
|
||||
!key.contains("HD_MODE") &&
|
||||
!key.contains("LENS") &&
|
||||
!key.contains("THUMBNAIL")
|
||||
) {
|
||||
return
|
||||
}
|
||||
synchronized(loggedOverrides) {
|
||||
if (!loggedOverrides.add(key)) return
|
||||
}
|
||||
context.log.info("Performance override applied: $key=$value", "PerformanceMode")
|
||||
}
|
||||
|
||||
overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() },
|
||||
{ true })
|
||||
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() },
|
||||
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null },
|
||||
{ true }, isAppExperiment = true)
|
||||
|
||||
run {
|
||||
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() }
|
||||
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
|
||||
val level7Value = { _: ConfigKeyInfo -> 700 }
|
||||
arrayOf(
|
||||
"MY_STORY_UPLOAD_QUALITY_LEVEL",
|
||||
@@ -72,10 +93,14 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
isForceQuality, { true })
|
||||
overrideProperty("MEDIA_QUALITY_LEVEL_DOWNGRADING_PERCENTAGE",
|
||||
isForceQuality, { 0.0f })
|
||||
overrideProperty("CHAT_MEDIA_IMPORT_TRANSCODED_QUALITY",
|
||||
isForceQuality, { 4 })
|
||||
overrideProperty("POSTED_STORY_IMPORT_TRANSCODED_QUALITY",
|
||||
isForceQuality, { 4 })
|
||||
}
|
||||
|
||||
run {
|
||||
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() }
|
||||
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
|
||||
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY", isDisableCompression, { 100 })
|
||||
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY_V2", isDisableCompression, { 100 })
|
||||
}
|
||||
@@ -84,6 +109,53 @@ 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 },
|
||||
{ true })
|
||||
overrideProperty("CAMERA_THREAD_PRIORITY", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ true })
|
||||
overrideProperty("HD_MODE_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() == "max" },
|
||||
{ true })
|
||||
|
||||
arrayOf(
|
||||
"FEATURE_PRELOADER",
|
||||
"SERVER_PREFETCH",
|
||||
"SERVER_PREFETCH_WITH_COF",
|
||||
"DISCOVER_FEED_PERFORMANCE",
|
||||
"LOGIN_PRELOAD",
|
||||
"PREFETCH_REPO_SUBSCRIBE_ON_CPU",
|
||||
"COMPUTE_FEED_CACHE_WITH_TTL",
|
||||
"COMPUTE_FEED_NETWORK_WITH_CACHE",
|
||||
"OPERA_WARMUP",
|
||||
"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 },
|
||||
{ false })
|
||||
overrideProperty("LOCK_SCREEN_ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
|
||||
{ false })
|
||||
overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() },
|
||||
{ true })
|
||||
|
||||
@@ -115,7 +187,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
|
||||
propertyOverrides[propertyKey.name]?.let { (filter, value) ->
|
||||
if (!filter(propertyKey)) return@let
|
||||
param.setResult(value(propertyKey))
|
||||
value(propertyKey).also {
|
||||
logPerformanceOverride(propertyKey.name ?: return@also, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +210,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
propertyOverrides[key]?.let { (filter, value) ->
|
||||
val keyInfo = getConfigKeyInfo(enumData) ?: return@let
|
||||
if (!filter(keyInfo)) return@let
|
||||
setValue(value(keyInfo))
|
||||
value(keyInfo).also {
|
||||
logPerformanceOverride(key, it)
|
||||
setValue(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +229,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}
|
||||
propertyOverrides[keyInfo.name]?.let { (filter, value, isAppExperiment) ->
|
||||
if (isAppExperiment != true || !filter(keyInfo)) return@let
|
||||
param.setResult(value(keyInfo))
|
||||
value(keyInfo).also {
|
||||
logPerformanceOverride(keyInfo.name ?: return@also, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +255,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
|
||||
}
|
||||
|
||||
val propertyOverride = propertyOverrides[keyInfo.name] ?: return@hook
|
||||
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let { param.setResult(it) }
|
||||
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let {
|
||||
logPerformanceOverride(keyInfo.name ?: return@let, it)
|
||||
param.setResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@@ -9,77 +10,99 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.app.ActivityManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import androidx.core.content.edit
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import me.eternal.purrfectsnap.bridge.AutoOpenInterface
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.MessageState
|
||||
import me.eternal.purrfectsnap.common.data.MessageUpdate
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.BuildMessageEvent
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.Message
|
||||
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.features.impl.tweaks.PerformanceMode
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.*
|
||||
import java.util.Objects
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.Calendar
|
||||
import kotlin.random.Random
|
||||
import me.eternal.purrfectsnap.bridge.AutoOpenInterface
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.random.Random
|
||||
|
||||
class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) {
|
||||
companion object {
|
||||
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_PAUSE_RESUME"
|
||||
const val ACTION_CLEAR_QUEUE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_CLEAR_QUEUE"
|
||||
private const val STATUS_NOTIFICATION_ID = 54321
|
||||
private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN"
|
||||
private const val PREF_TOTAL_OPENED = "auto_open_total_opened"
|
||||
private const val PREF_TOTAL_DETECTED = "auto_open_total_detected"
|
||||
private const val PREF_SESSION_START = "auto_open_session_start"
|
||||
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()
|
||||
|
||||
private val lastNotificationUpdate = AtomicLong(0)
|
||||
private val notificationUpdateDelay = 1000L
|
||||
private val pendingNotificationUpdate = AtomicBoolean(false)
|
||||
private val snapTimestamps = LinkedList<Long>()
|
||||
|
||||
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",
|
||||
@@ -88,34 +111,32 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
)
|
||||
|
||||
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())
|
||||
synchronized(queuedSnaps) { queuedSnaps.clear() }
|
||||
synchronized(deadLetterQueue) { deadLetterQueue.clear() }
|
||||
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() {
|
||||
@@ -124,160 +145,137 @@ 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 {
|
||||
val pauseStarted = lastPausedAt.get()
|
||||
if (pauseStarted > 0) {
|
||||
totalPausedDuration.addAndGet(System.currentTimeMillis() - pauseStarted)
|
||||
lastPausedAt.set(0)
|
||||
}
|
||||
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 -> {
|
||||
clearInternalState()
|
||||
this@AutoOpenSnaps.context.log.info("[AutoOpen] All statistics and queue reset.")
|
||||
updateStatusNotification(force = true)
|
||||
}
|
||||
ACTION_CLEAR_QUEUE -> clearInternalState()
|
||||
Intent.ACTION_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) }
|
||||
Intent.ACTION_SCREEN_OFF -> isScreenOn.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (config.globalState != true) return
|
||||
context.log.info("[AutoOpen] Initializing Ultra Premium engine...")
|
||||
|
||||
val messaging = context.feature(Messaging::class)
|
||||
restorePersistence()
|
||||
hasBeenActive.set(true)
|
||||
hasBeenActive.set(config.globalState == true)
|
||||
|
||||
if (config.allowRunningInBackground.get()) {
|
||||
acquireWakeLock()
|
||||
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
|
||||
hook("appStateChanged", HookStage.BEFORE) { param ->
|
||||
if (config.allowRunningInBackground.get()) {
|
||||
val state = param.arg<Any>(0).toString()
|
||||
if (state == "INACTIVE" || state == "BACKGROUND") {
|
||||
param.setResult(null)
|
||||
}
|
||||
if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null)
|
||||
}
|
||||
}
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
methods.first { it.name == "appStateChanged" }.let { method ->
|
||||
method.invoke(param.thisObject(), method.parameterTypes[0].enumConstants!!.first { it.toString() == "ACTIVE" })
|
||||
methods.firstOrNull { it.name == "appStateChanged" }?.let { method ->
|
||||
val enumClass = method.parameterTypes[0]
|
||||
val activeState = enumClass.enumConstants?.firstOrNull { it.toString() == "ACTIVE" || it.toString() == "FOREGROUND" }
|
||||
if (activeState != null) method.invoke(param.thisObject<Any>(), activeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(Intent.ACTION_BATTERY_CHANGED); addAction(Intent.ACTION_SCREEN_ON); addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter)
|
||||
|
||||
val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_BATTERY_CHANGED && config.thermalProtection.get()) {
|
||||
val temp = intent.getIntExtra("temperature", 0) / 10f
|
||||
if (temp >= 40f && !isThermalThrottled) {
|
||||
isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis()
|
||||
} else if (isThermalThrottled && temp <= 36f && System.currentTimeMillis() - lastThermalThrottleAt > 600000) {
|
||||
isThermalThrottled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
context.androidContext.registerReceiver(batteryReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter)
|
||||
context.androidContext.registerReceiver(batteryReceiver, filter)
|
||||
}
|
||||
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
|
||||
// Watchdog Loop
|
||||
context.coroutineScope.launch(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
if (config.globalState != true) {
|
||||
shutdownFeature()
|
||||
break
|
||||
}
|
||||
if (config.globalState != true) { shutdownFeature(); break }
|
||||
val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
if (remainingCount == 0 && sessionProcessed.get() > 0) {
|
||||
sessionProcessed.set(0)
|
||||
|
||||
if (remainingCount > 0) {
|
||||
lastQueueActivity = System.currentTimeMillis(); acquireWakeLock()
|
||||
if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
} else {
|
||||
if (!isPaused.get() && System.currentTimeMillis() - lastQueueActivity > 300000) {
|
||||
val revived = synchronized(deadLetterQueue) { if (deadLetterQueue.isNotEmpty()) deadLetterQueue.removeAt(0) else null }
|
||||
if (revived != null) { synchronized(queuedSnaps) { queuedSnaps.add(revived) }; snapQueue.tryEmit(System.currentTimeMillis()) }
|
||||
}
|
||||
if (System.currentTimeMillis() - lastQueueActivity > 300000) {
|
||||
startWakeLockCooldown()
|
||||
}
|
||||
}
|
||||
updateStatusNotification()
|
||||
if (remainingCount > 0) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
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) {
|
||||
val item = synchronized(queuedSnaps) { queuedSnaps.firstOrNull() } ?: break
|
||||
|
||||
while (isPaused.get() || config.globalState != true) {
|
||||
if (config.globalState != true) return@collect
|
||||
currentStatusText = context.translation["auto_open_snaps.status_paused"] ?: "Paused"
|
||||
isCurrentlyWaiting = true
|
||||
updateStatusNotification()
|
||||
delay(2000)
|
||||
}
|
||||
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 = context.translation["auto_open_snaps.paused_status"] ?: "Paused (No WiFi)"
|
||||
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 = context.translation["auto_open_snaps.paused_status"] ?: "Paused (Device Active)"
|
||||
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 = context.translation["auto_open_snaps.paused_status"] ?: "Paused (Gaming)"
|
||||
isCurrentlyWaiting = true
|
||||
delay(60000)
|
||||
}
|
||||
else -> {
|
||||
resourceWaiting = false
|
||||
currentSpeedText = if (inSleepWindow) context.translation["auto_open_snaps.speed_throttled"] ?: "Throttled" else context.translation["auto_open_snaps.processing_speed_full"] ?: "Full Speed"
|
||||
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) continue
|
||||
if (isPaused.get() || config.globalState != true) { synchronized(queuedSnaps) { queuedSnaps.add(0, item) }; continue }
|
||||
isCurrentlyWaiting = false
|
||||
|
||||
val inSleepWindow = if (config.onlyWhenIdle.get()) isInsideSleepWindow() else false
|
||||
if (inSleepWindow) {
|
||||
currentStatusText = context.translation["auto_open_snaps.speed_throttled"] ?: "Throttled"
|
||||
delay(Random.nextLong(3000, 5000))
|
||||
} else if (lastConversationId != null && lastConversationId != item.conversationId) {
|
||||
currentStatusText = "..."
|
||||
delay(Random.nextLong(1500, 2500))
|
||||
batchSnapCount = 0
|
||||
}
|
||||
|
||||
// TIMING: 40ms switch
|
||||
if (lastConversationId != null && lastConversationId != item.conversationId) { delay(40) }
|
||||
lastConversationId = item.conversationId
|
||||
currentStatusText = if (inSleepWindow) context.translation["auto_open_snaps.status_active"] ?: "Active" else context.translation["auto_open_snaps.status_active"] ?: "Opening snap..."
|
||||
updateStatusNotification()
|
||||
|
||||
if (config.safeProcessing.get() && !inSleepWindow) {
|
||||
batchSnapCount++
|
||||
if (batchSnapCount % 10 == 0) {
|
||||
currentStatusText = "..."
|
||||
updateStatusNotification()
|
||||
delay(Random.nextLong(3000, 5000))
|
||||
}
|
||||
}
|
||||
currentStatusText = "Active"; updateStatusNotification()
|
||||
|
||||
var success = false
|
||||
val startTime = System.currentTimeMillis()
|
||||
@@ -285,253 +283,263 @@ 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 = "..."
|
||||
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()
|
||||
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())
|
||||
saveStatsToDisk()
|
||||
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) {
|
||||
synchronized(queuedSnaps) { queuedSnaps.removeAll { it.messageId == item.messageId }; saveQueueToDisk() }
|
||||
} else if (!isPaused.get()) {
|
||||
currentStatusText = context.translation["auto_open_snaps.status_failed"]?.replace("{sender}", item.senderName) ?: "Failed to open"
|
||||
updateStatusNotification()
|
||||
delay(5000)
|
||||
synchronized(queuedSnaps) { queuedSnaps.removeAll { it.messageId == item.messageId }; saveQueueToDisk() }
|
||||
if (!success && !isPaused.get()) {
|
||||
currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification()
|
||||
synchronized(openedSnaps) { openedSnaps.remove(item.messageId) }
|
||||
synchronized(deadLetterQueue) { if (deadLetterQueue.size < 100) deadLetterQueue.add(item) else { deadLetterQueue.removeAt(0); deadLetterQueue.add(item) } }
|
||||
}
|
||||
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
|
||||
currentStatusText = context.translation["auto_open_snaps.status_monitoring"] ?: "Monitoring..."
|
||||
isCurrentlyWaiting = false
|
||||
updateStatusNotification()
|
||||
releaseWakeLock()
|
||||
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
|
||||
|
||||
context.coroutineScope.launch(Dispatchers.Default) {
|
||||
if (!canUseRule(conversationId)) return@launch
|
||||
if (!openedSnaps.add(clientMessageId)) return@launch
|
||||
if (openedSnaps.size > 15000) openedSnaps.clear()
|
||||
val senderId = event.message.senderId?.toString() ?: "unknown"
|
||||
val item = SnapQueueItem(conversationId, clientMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
|
||||
synchronized(queuedSnaps) {
|
||||
if (queuedSnaps.size >= config.queueSize.get()) queuedSnaps.removeFirstOrNull()
|
||||
queuedSnaps.add(item); totalDetected.incrementAndGet(); saveQueueToDisk()
|
||||
}
|
||||
acquireWakeLock()
|
||||
snapQueue.tryEmit(System.currentTimeMillis())
|
||||
acquireWakeLock()
|
||||
synchronized(openedSnaps) {
|
||||
if (openedSnaps.contains(clientMessageId)) return@subscribe
|
||||
openedSnaps.add(clientMessageId)
|
||||
if (openedSnaps.size > 5000) openedSnaps.clear()
|
||||
}
|
||||
|
||||
val senderId = message.senderId?.toString() ?: "unknown"
|
||||
val item = SnapQueueItem(conversationId, clientMessageId, serverMsgId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
|
||||
|
||||
synchronized(queuedSnaps) {
|
||||
if (queuedSnaps.size >= config.queueSize.get()) queuedSnaps.removeFirstOrNull()
|
||||
queuedSnaps.add(item)
|
||||
}
|
||||
|
||||
if (context.config.messaging.preFetchSnaps.get()) {
|
||||
runCatching { messaging.conversationManager?.fetchMessage(conversationId, clientMessageId, {}, {}) }
|
||||
}
|
||||
|
||||
if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
updateStatusNotification()
|
||||
triggerLazySave()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performOpen(messaging: Messaging, item: SnapQueueItem): Boolean = withContext(Dispatchers.IO) {
|
||||
val manager = messaging.conversationManager ?: return@withContext false
|
||||
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) }
|
||||
}
|
||||
}.onFailure { cont.resume(false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSnapsPerSecond(): Double {
|
||||
val now = System.currentTimeMillis(); val window = 5000L
|
||||
synchronized(snapTimestamps) {
|
||||
snapTimestamps.removeIf { now - it > window }; return (snapTimestamps.size.toDouble() / (window / 1000.0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatusNotification(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() }
|
||||
}
|
||||
} ?: false
|
||||
return
|
||||
}
|
||||
lastNotificationUpdate.set(currentTime); updateStatusNotificationInternal()
|
||||
}
|
||||
|
||||
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" }
|
||||
}
|
||||
private var lastNotificationStateHash: Int = 0
|
||||
|
||||
private fun updateStatusNotification() {
|
||||
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 showProgressBar = config.showProgressBar.get() == true
|
||||
val isCompact = config.compactNotification.get() == true
|
||||
val sessionTotal = processed + remaining
|
||||
val speed = getSnapsPerSecond()
|
||||
val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0
|
||||
val eta = if (isWorking && !isCurrentlyWaiting && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..."
|
||||
|
||||
val builder = Notification.Builder(context.androidContext, "auto_open_snaps")
|
||||
.setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play)
|
||||
.setOngoing(isWorking).setAutoCancel(!isWorking).setOnlyAlertOnce(true)
|
||||
.setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY).setGroupSummary(false)
|
||||
|
||||
// Disable native progress bar to avoid "Double Bar" issue with our Premium Unicode bar
|
||||
builder.setProgress(0, 0, false)
|
||||
|
||||
val eta = if (isWorking && !isCurrentlyWaiting && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else null
|
||||
|
||||
if (config.compactNotification.get() == true) {
|
||||
builder.setContentTitle("Auto-Open: $currentStatusText")
|
||||
builder.setContentText("Remaining: $remaining | Opened: $processed" + (eta?.let { " | ETA: $it" } ?: ""))
|
||||
builder.setContentTitle("Auto-Open: $currentStatusText")
|
||||
|
||||
if (isWorking) {
|
||||
builder.setContentText("Opened: $processed │ Queue: $remaining")
|
||||
builder.setSubText("$progressPercent% • Ends in: ${eta ?: "..."}")
|
||||
builder.setProgress(sessionTotal, processed, false)
|
||||
} else {
|
||||
builder.setContentTitle("Auto-Open: $currentStatusText")
|
||||
builder.setContentText("Remaining: $remaining | Opened: $processed")
|
||||
builder.setContentText("$processed Opened Today │ $total Total")
|
||||
builder.setSubText(null)
|
||||
builder.setProgress(0, 0, false)
|
||||
}
|
||||
|
||||
builder.addAction(Notification.Action.Builder(null, if (isPaused.get()) "Resume" else "Pause", createPendingIntent(ACTION_PAUSE_RESUME)).build())
|
||||
builder.addAction(Notification.Action.Builder(null, "Clear Queue", createPendingIntent(ACTION_CLEAR_QUEUE)).build())
|
||||
|
||||
if (config.compactNotification.get() != true) {
|
||||
if (!isCompact) {
|
||||
val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) }
|
||||
val bigTextStyle = Notification.BigTextStyle()
|
||||
val bigTextStyle = Notification.BigTextStyle().setSummaryText("")
|
||||
val detailText = buildString {
|
||||
if (showProgressBar) append("${context.translation["auto_open_snaps.notification_statistics"] ?: "STATISTICS"} - ${drawProgressBar(progressPercent)}\n")
|
||||
else append("${context.translation["auto_open_snaps.notification_statistics"] ?: "STATISTICS"}\n")
|
||||
|
||||
append("\u251c\u2500 ${context.translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processed snaps\n")
|
||||
append("\u251c\u2500 ${context.translation["auto_open_snaps.queue_size"] ?: "Remaining"}: $remaining snaps\n")
|
||||
|
||||
if (eta != null) {
|
||||
append("\u251c\u2500 ${context.translation["auto_open_snaps.estimated_time"] ?: "Estimated time"}: $eta\n")
|
||||
}
|
||||
|
||||
if (config.showLifetimeStats.get()) {
|
||||
append("\u251c\u2500 ${context.translation["auto_open_snaps.notification_total_opened"] ?: "Lifetime Opened"}: $total snaps\n")
|
||||
}
|
||||
|
||||
append("QUEUE STATISTICS\n")
|
||||
append("├─ Opened: $processed snaps\n")
|
||||
append("├─ Queue: $remaining snaps\n")
|
||||
append("├─ Total Opened: $total snaps\n")
|
||||
val speedNotion = if (remaining > 0) currentSpeedText else "Idle"
|
||||
val speedValue = if (remaining > 0) "${String.format("%.1f", speed)}/s" else "0.0/s"
|
||||
append("└─ Speed: $speedNotion ($speedValue)\n\n")
|
||||
|
||||
|
||||
if (config.showQueuePreview.get()) {
|
||||
append("\u2514\u2500 ${context.translation["auto_open_snaps.processing_speed"] ?: "Speed"}: $currentSpeedText\n\n${context.translation["auto_open_snaps.notification_queue_preview"] ?: "QUEUE PREVIEW"}\n")
|
||||
append("\n\nQUEUE PREVIEW\n")
|
||||
if (isWorking) {
|
||||
recentSnaps.reversed().forEach { item ->
|
||||
append("\u2022 ${item.senderName}")
|
||||
if (item.conversationType != "Friend DM" && item.conversationType != "Processing") append(" \u2502 ${item.conversationType}")
|
||||
append(" (${item.contentType})\n")
|
||||
append("• ${item.senderName} │ ${item.conversationType} (${item.contentType})\n")
|
||||
}
|
||||
} else append(context.translation["auto_open_snaps.notification_no_snaps_queue"] ?: "Monitoring snaps in background...")
|
||||
} else append("\u2514\u2500 ${context.translation["auto_open_snaps.processing_speed"] ?: "Speed"}: $currentSpeedText")
|
||||
} else {
|
||||
append("Monitoring snaps in background...")
|
||||
}
|
||||
}
|
||||
}
|
||||
bigTextStyle.bigText(detailText); builder.setStyle(bigTextStyle)
|
||||
bigTextStyle.bigText(detailText)
|
||||
builder.setStyle(bigTextStyle)
|
||||
}
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun drawProgressBar(percent: Int): String {
|
||||
val totalBlocks = 12; val filledBlocks = (percent * totalBlocks) / 100
|
||||
return buildString {
|
||||
append("[")
|
||||
repeat(totalBlocks) { i ->
|
||||
when { i < filledBlocks -> append("\u2501"); i == filledBlocks -> append("\u2B26"); else -> append("\u2500") }
|
||||
}
|
||||
append("] $percent%")
|
||||
}
|
||||
private fun 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")
|
||||
.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()
|
||||
}
|
||||
resetPersistence(); releaseWakeLock()
|
||||
}
|
||||
|
||||
private fun saveStatsToDisk() {
|
||||
prefs.edit().putInt(PREF_TOTAL_OPENED, totalProcessed.get()).putInt(PREF_TOTAL_DETECTED, totalDetected.get()).putLong(PREF_SESSION_START, sessionStartTime.get()).apply()
|
||||
private fun triggerLazySave() {
|
||||
needsSaving.set(true)
|
||||
if (isSaving.compareAndSet(false, true)) {
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
while (needsSaving.get()) { needsSaving.set(false); saveToDiskInternal(); delay(300000) }
|
||||
isSaving.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveQueueToDisk() {
|
||||
synchronized(queuedSnaps) { prefs.edit().putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps)).apply() }
|
||||
private fun saveToDiskInternal() {
|
||||
prefs.edit {
|
||||
putInt(PREF_TOTAL_OPENED, totalProcessed.get())
|
||||
putLong(PREF_SESSION_START, sessionStartTime.get())
|
||||
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)
|
||||
synchronized(queuedSnaps) { queuedSnaps.clear(); queuedSnaps.addAll(restored) }
|
||||
} catch (e: Exception) { resetPersistence() }
|
||||
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 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 window = config.sleepWindow.get().split("-"); if (window.size != 2) return false
|
||||
val start = window[0].split(":"); val end = window[1].split(":")
|
||||
val now = Calendar.getInstance().apply { set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
|
||||
val s = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, start[0].toInt()); set(Calendar.MINUTE, start[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
|
||||
val e = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, end[0].toInt()); set(Calendar.MINUTE, end[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
|
||||
return if (e.before(s)) now.after(s) || now.before(e) else now.after(s) && now.before(e)
|
||||
} catch (e: Exception) { return false }
|
||||
}
|
||||
|
||||
private fun 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) {
|
||||
@@ -544,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,9 +296,9 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
|
||||
private fun getRandomizedSettingsToggleState(): RandomizedSettingsToggleState {
|
||||
val config = context.config.experimental.spoof.randomizeDeviceProfile.settingsOptions
|
||||
val secure = config.secure
|
||||
val system = config.system
|
||||
val global = config.global
|
||||
val secure = config.secureSettings
|
||||
val system = config.systemSettings
|
||||
val global = config.globalSettings
|
||||
return RandomizedSettingsToggleState(
|
||||
secure = secure.globalState == true && (secure.base.get() || secure.tts.get()),
|
||||
system = system.globalState == true && (system.base.get() || system.bluetooth.get()),
|
||||
@@ -357,6 +357,10 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
supported32BitAbis: List<String>? = null,
|
||||
supported64BitAbis: List<String>? = null
|
||||
) {
|
||||
val safeSupportedAbis = supportedAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_ABIS?.toList() ?: emptyList())
|
||||
val safeSupported32BitAbis = supported32BitAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_32_BIT_ABIS?.toList() ?: emptyList())
|
||||
val safeSupported64BitAbis = supported64BitAbis?.takeIf { it.isNotEmpty() } ?: (Build.SUPPORTED_64_BIT_ABIS?.toList() ?: emptyList())
|
||||
|
||||
Build::class.java.fields.forEach { field ->
|
||||
if (!field.isAccessible) field.isAccessible = true
|
||||
runCatching {
|
||||
@@ -377,9 +381,9 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
|
||||
"DISPLAY" -> if (overrideDisplay) runCatching { field.set(null, display) }
|
||||
"HOST" -> if (overrideHost) runCatching { field.set(null, host) }
|
||||
"TIME" -> if (overrideBuildTime) runCatching { field.setLong(null, buildTime) }
|
||||
"SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supportedAbis?.toTypedArray()) }
|
||||
"SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported32BitAbis?.toTypedArray()) }
|
||||
"SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, supported64BitAbis?.toTypedArray()) }
|
||||
"SUPPORTED_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupportedAbis.toTypedArray()) }
|
||||
"SUPPORTED_32_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupported32BitAbis.toTypedArray()) }
|
||||
"SUPPORTED_64_BIT_ABIS" -> if (overrideAbiLists) runCatching { field.set(null, safeSupported64BitAbis.toTypedArray()) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ object RandomizedDeviceProfileStore {
|
||||
bluetoothMacAddress = bluetoothMac,
|
||||
ipAddress = ipAddress,
|
||||
wifiSsid = region.randomWifiSsid(),
|
||||
wifiRssi = random.nextInt(-72, -36),
|
||||
wifiRssi = randomInt(-72, -36),
|
||||
localeTag = locale.toLanguageTag(),
|
||||
countryIso = region.countryIso,
|
||||
timeZoneId = region.timeZoneId,
|
||||
@@ -450,7 +450,7 @@ object RandomizedDeviceProfileStore {
|
||||
|
||||
private fun randomPublicIpv4(prefixes: List<Int>? = null): String {
|
||||
val firstOctet = prefixes?.takeIf { it.isNotEmpty() }?.let { pick(it) } ?: run {
|
||||
generateSequence { random.nextInt(1, 224) }
|
||||
generateSequence { randomInt(1, 224) }
|
||||
.first { candidate ->
|
||||
candidate != 10 &&
|
||||
candidate != 127 &&
|
||||
@@ -459,13 +459,18 @@ object RandomizedDeviceProfileStore {
|
||||
candidate != 192
|
||||
}
|
||||
}
|
||||
val secondOctet = random.nextInt(1, 255)
|
||||
val thirdOctet = random.nextInt(1, 255)
|
||||
val fourthOctet = random.nextInt(2, 255)
|
||||
val secondOctet = randomInt(1, 255)
|
||||
val thirdOctet = randomInt(1, 255)
|
||||
val fourthOctet = randomInt(2, 255)
|
||||
val candidate = "$firstOctet.$secondOctet.$thirdOctet.$fourthOctet"
|
||||
return runCatching { InetAddress.getByName(candidate).hostAddress }.getOrDefault(candidate)
|
||||
}
|
||||
|
||||
private fun randomInt(minInclusive: Int, maxExclusive: Int): Int {
|
||||
require(maxExclusive > minInclusive)
|
||||
return minInclusive + random.nextInt(maxExclusive - minInclusive)
|
||||
}
|
||||
|
||||
private fun randomLong(minInclusive: Long, maxExclusive: Long): Long {
|
||||
require(maxExclusive > minInclusive)
|
||||
val bound = maxExclusive - minInclusive
|
||||
|
||||
@@ -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("|")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import me.eternal.purrfectsnap.core.features.Feature
|
||||
|
||||
class DisableMetrics : Feature("DisableMetrics") {
|
||||
override fun init() {
|
||||
if (!context.config.global.disableMetrics.get()) return
|
||||
if (!context.config.global.disableMetrics.get() && context.config.global.performanceMode.profile.getNullable() == null) return
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { param ->
|
||||
val url = param.url
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
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.media.MediaRecorder
|
||||
import android.os.HandlerThread
|
||||
import android.os.Process
|
||||
import android.util.Base64
|
||||
import android.util.Range
|
||||
import android.view.View
|
||||
import android.widget.OverScroller
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
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,
|
||||
val longValue: Long? = null,
|
||||
val doubleValue: Double? = null,
|
||||
val blobValue: String? = null,
|
||||
)
|
||||
|
||||
private data class CursorSnapshot(
|
||||
val columns: List<String>,
|
||||
val rows: List<List<SnapshotCell>>,
|
||||
)
|
||||
|
||||
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"
|
||||
val threadPriority = if (isMaxProfile) {
|
||||
Process.THREAD_PRIORITY_DISPLAY
|
||||
} else {
|
||||
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
|
||||
val maxRequestsPerHost = if (isMaxProfile) 32 else 16
|
||||
val minimumCoreThreads = if (isMaxProfile) 16 else 8
|
||||
val prefetchItemCount = if (isMaxProfile) 24 else 12
|
||||
val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L
|
||||
val maxScrollDurationMs = if (isMaxProfile) 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, 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"
|
||||
)
|
||||
|
||||
runCatching {
|
||||
ValueAnimator.setFrameDelay(0L)
|
||||
context.log.info("Applied ValueAnimator frame delay override: 0ms", "PerformanceMode")
|
||||
}
|
||||
|
||||
fun firstHitLogger(name: String): (String) -> Unit {
|
||||
val didLog = AtomicBoolean(false)
|
||||
return { details ->
|
||||
if (didLog.compareAndSet(false, true)) {
|
||||
context.log.info("First hit: $name | $details", "PerformanceMode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val handlerThreadConstructorLog = firstHitLogger("HandlerThread.constructor")
|
||||
val handlerThreadStartLog = firstHitLogger("HandlerThread.start")
|
||||
val threadStartLog = firstHitLogger("Thread.start")
|
||||
val executorLog = firstHitLogger("ThreadPoolExecutor.constructor")
|
||||
val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor")
|
||||
val animatorLog = firstHitLogger("ValueAnimator.getDurationScale")
|
||||
val 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 overScrollerLog = firstHitLogger("OverScroller.startScroll")
|
||||
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("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()
|
||||
if (!normalized.startsWith("SELECT")) return false
|
||||
val hitsFriendsFeedView = sql.contains("FriendsFeedView")
|
||||
val hitsFeedEntry = sql.contains("feed_entry") && (sql.contains("last_updated_timestamp") || sql.contains("displayInteractionType") || sql.contains("streak_count"))
|
||||
return (hitsFriendsFeedView || hitsFeedEntry) &&
|
||||
!normalized.contains("COUNT(") &&
|
||||
!normalized.contains("SELECT 0") &&
|
||||
!normalized.contains("WHERE KEY = ?") &&
|
||||
!normalized.contains("WHERE CLIENT_CONVERSATION_ID = ?")
|
||||
}
|
||||
|
||||
fun cursorCell(cursor: Cursor, index: Int): SnapshotCell {
|
||||
return when (cursor.getType(index)) {
|
||||
Cursor.FIELD_TYPE_NULL -> SnapshotCell(Cursor.FIELD_TYPE_NULL)
|
||||
Cursor.FIELD_TYPE_INTEGER -> SnapshotCell(Cursor.FIELD_TYPE_INTEGER, longValue = cursor.getLong(index))
|
||||
Cursor.FIELD_TYPE_FLOAT -> SnapshotCell(Cursor.FIELD_TYPE_FLOAT, doubleValue = cursor.getDouble(index))
|
||||
Cursor.FIELD_TYPE_STRING -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
|
||||
Cursor.FIELD_TYPE_BLOB -> SnapshotCell(
|
||||
Cursor.FIELD_TYPE_BLOB,
|
||||
blobValue = 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))
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshotFromCursor(cursor: Cursor): CursorSnapshot {
|
||||
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) }
|
||||
rowCount++
|
||||
} while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext())
|
||||
}
|
||||
return CursorSnapshot(columns, rows)
|
||||
}
|
||||
|
||||
fun snapshotToMatrixCursor(snapshot: CursorSnapshot): MatrixCursor {
|
||||
return MatrixCursor(snapshot.columns.toTypedArray(), snapshot.rows.size).also { matrixCursor ->
|
||||
snapshot.rows.forEach { row ->
|
||||
matrixCursor.addRow(row.map { cell ->
|
||||
when (cell.type) {
|
||||
Cursor.FIELD_TYPE_NULL -> null
|
||||
Cursor.FIELD_TYPE_INTEGER -> cell.longValue
|
||||
Cursor.FIELD_TYPE_FLOAT -> cell.doubleValue
|
||||
Cursor.FIELD_TYPE_BLOB -> cell.blobValue?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
else -> cell.stringValue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun readSnapshot(file: File): CursorSnapshot? {
|
||||
return runCatching {
|
||||
if (!file.exists()) return null
|
||||
context.gson.fromJson(file.readText(Charsets.UTF_8), CursorSnapshot::class.java)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun writeSnapshot(file: File, snapshot: CursorSnapshot) {
|
||||
runCatching {
|
||||
file.writeText(context.gson.toJson(snapshot), Charsets.UTF_8)
|
||||
}.onFailure {
|
||||
context.log.error("Failed to persist friend list snapshot", it, "PerformanceMode")
|
||||
}
|
||||
}
|
||||
|
||||
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
|
||||
if (!isMaxProfile) return@subscribe
|
||||
val url = event.url
|
||||
if (url.contains("ami/friends")) {
|
||||
if (chatFeedSnapshotFile.exists()) {
|
||||
chatFeedSnapshotFile.delete()
|
||||
context.log.info("Invalidated chat feed snapshot after friends mutation sync", "PerformanceMode")
|
||||
}
|
||||
}
|
||||
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
|
||||
event.canceled = true
|
||||
mapboxNetworkBlockLog("url=$url")
|
||||
}
|
||||
}
|
||||
|
||||
HandlerThread::class.java.hookConstructor(HookStage.BEFORE) { param ->
|
||||
if (param.args().size < 2) return@hookConstructor
|
||||
val threadName = param.argNullable<String>(0)
|
||||
if (!isPerformanceSensitiveThread(threadName)) return@hookConstructor
|
||||
param.setArg(1, threadPriority)
|
||||
handlerThreadConstructorLog("name=$threadName priority=$threadPriority")
|
||||
}
|
||||
|
||||
HandlerThread::class.java.hook("start", HookStage.AFTER) { param ->
|
||||
val thread = param.nullableThisObject<Any>() as? HandlerThread ?: return@hook
|
||||
if (!isPerformanceSensitiveThread(thread.name)) return@hook
|
||||
runCatching {
|
||||
val tid = thread.threadId
|
||||
if (tid > 0) {
|
||||
Process.setThreadPriority(tid, threadPriority)
|
||||
}
|
||||
}
|
||||
handlerThreadStartLog("name=${thread.name} tid=${thread.threadId} priority=$threadPriority")
|
||||
}
|
||||
|
||||
Thread::class.java.hook("start", HookStage.AFTER) { param ->
|
||||
val thread = param.thisObject<Thread>()
|
||||
if (!isPerformanceSensitiveThread(thread.name)) return@hook
|
||||
runCatching {
|
||||
thread.priority = preferredJavaThreadPriority
|
||||
}
|
||||
threadStartLog("name=${thread.name} priority=${thread.priority}")
|
||||
if ((thread.name ?: "").contains("map", ignoreCase = true) || (thread.name ?: "").contains("mapbox", ignoreCase = true)) {
|
||||
mapThreadLog("name=${thread.name} priority=${thread.priority}")
|
||||
}
|
||||
}
|
||||
|
||||
ThreadPoolExecutor::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val executor = param.thisObject<ThreadPoolExecutor>()
|
||||
runCatching {
|
||||
val targetCorePoolSize = executor.maximumPoolSize.coerceAtLeast(1).coerceAtMost(minimumCoreThreads.coerceAtLeast(executor.corePoolSize))
|
||||
if (executor.corePoolSize < targetCorePoolSize) {
|
||||
executor.corePoolSize = targetCorePoolSize
|
||||
}
|
||||
executor.allowCoreThreadTimeOut(false)
|
||||
executor.prestartAllCoreThreads()
|
||||
executorLog("core=${executor.corePoolSize} max=${executor.maximumPoolSize} active=${executor.activeCount}")
|
||||
}
|
||||
}
|
||||
|
||||
Dispatcher::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val dispatcher = param.thisObject<Dispatcher>()
|
||||
runCatching {
|
||||
dispatcher.maxRequests = maxRequests
|
||||
dispatcher.maxRequestsPerHost = maxRequestsPerHost
|
||||
dispatcherLog("maxRequests=${dispatcher.maxRequests} maxRequestsPerHost=${dispatcher.maxRequestsPerHost}")
|
||||
}
|
||||
}
|
||||
|
||||
ValueAnimator::class.java.hook("getDurationScale", HookStage.AFTER) { param ->
|
||||
param.setResult(durationScale)
|
||||
animatorLog("durationScale=$durationScale")
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
|
||||
recyclerView.overScrollMode = View.OVER_SCROLL_NEVER
|
||||
if (isMaxProfile) {
|
||||
recyclerView.itemAnimator = null
|
||||
}
|
||||
recyclerCtorLog("cache=$recyclerViewCacheSize max=$isMaxProfile class=${recyclerView::class.java.name}")
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
|
||||
if (isMaxProfile) {
|
||||
recyclerView.itemAnimator = null
|
||||
}
|
||||
recyclerAdapterLog("cache=$recyclerViewCacheSize adapter=${param.argNullable<Any>(0)?.javaClass?.name}")
|
||||
}
|
||||
|
||||
RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param ->
|
||||
val recyclerView = param.thisObject<RecyclerView>()
|
||||
val layoutManager = param.argNullable<Any>(0)
|
||||
when (layoutManager) {
|
||||
is LinearLayoutManager -> {
|
||||
layoutManager.isItemPrefetchEnabled = true
|
||||
layoutManager.initialPrefetchItemCount = prefetchItemCount
|
||||
}
|
||||
is StaggeredGridLayoutManager -> {
|
||||
layoutManager.isItemPrefetchEnabled = true
|
||||
layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
|
||||
}
|
||||
}
|
||||
recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount")
|
||||
}
|
||||
|
||||
fun SQLiteDatabase.applyPerformancePragmas() {
|
||||
runCatching { execSQL("PRAGMA synchronous = NORMAL") }
|
||||
runCatching { execSQL("PRAGMA temp_store = MEMORY") }
|
||||
runCatching { execSQL("PRAGMA cache_size = -32768") }
|
||||
runCatching { execSQL("PRAGMA mmap_size = 268435456") }
|
||||
runCatching { execSQL("PRAGMA journal_size_limit = 1048576") }
|
||||
runCatching { execSQL("PRAGMA optimize") }
|
||||
}
|
||||
|
||||
SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param ->
|
||||
(param.getResult() as? SQLiteDatabase)?.also {
|
||||
it.applyPerformancePragmas()
|
||||
sqliteOpenLog("path=${param.argNullable<Any>(0)}")
|
||||
}
|
||||
}
|
||||
|
||||
SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param ->
|
||||
(param.getResult() as? SQLiteDatabase)?.also {
|
||||
it.applyPerformancePragmas()
|
||||
sqliteCreateLog("path=${param.argNullable<Any>(0)}")
|
||||
}
|
||||
}
|
||||
|
||||
MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param ->
|
||||
val currentRate = param.arg<Int>(0)
|
||||
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)}")
|
||||
}
|
||||
|
||||
OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param ->
|
||||
if (param.args().size >= 5) {
|
||||
val original = param.arg<Int>(4)
|
||||
val updated = original.coerceAtMost(maxScrollDurationMs)
|
||||
if (updated != original) {
|
||||
param.setArg(4, updated)
|
||||
}
|
||||
overScrollerLog("requested=$original applied=${param.arg<Int>(4)}")
|
||||
}
|
||||
}
|
||||
|
||||
OverScroller::class.java.hook("fling", HookStage.BEFORE) { param ->
|
||||
if (param.args().size >= 10) {
|
||||
val overX = param.arg<Int>(8)
|
||||
val overY = param.arg<Int>(9)
|
||||
if (overX != 0) param.setArg(8, 0)
|
||||
if (overY != 0) param.setArg(9, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyActivityPerformanceTuning(activity: Activity) {
|
||||
runCatching {
|
||||
activity.window.setWindowAnimations(0)
|
||||
}
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
applyActivityPerformanceTuning(it)
|
||||
}
|
||||
|
||||
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
|
||||
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
|
||||
val window = dialog.window ?: return@hook
|
||||
runCatching {
|
||||
window.setWindowAnimations(0)
|
||||
if (dialog::class.java.name.contains("map", ignoreCase = true) || dialog::class.java.name.contains("snap", ignoreCase = true)) {
|
||||
mapDialogLog("class=${dialog::class.java.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.mapbox.mapboxsdk.maps.MapView").hookConstructor(HookStage.AFTER) { param ->
|
||||
val mapView = param.nullableThisObject<Any>() as? View ?: return@hookConstructor
|
||||
mapView.overScrollMode = View.OVER_SCROLL_NEVER
|
||||
mapViewLog("class=${mapView::class.java.name}")
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
findClass("com.mapbox.mapboxsdk.maps.renderer.MapRenderer").hook("setMaximumFps", HookStage.BEFORE) { param ->
|
||||
val requested = param.arg<Int>(0)
|
||||
val applied = requested.coerceAtLeast(120)
|
||||
if (applied != requested) {
|
||||
param.setArg(0, applied)
|
||||
}
|
||||
mapRendererFpsLog("requested=$requested applied=${param.arg<Int>(0)}")
|
||||
}
|
||||
}
|
||||
|
||||
runCatching {
|
||||
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))
|
||||
chatFeedSnapshotServedThisProcess.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.AFTER) { param ->
|
||||
if (!isMaxProfile) return@hook
|
||||
val sql = param.argNullable<String>(1) ?: return@hook
|
||||
if (!isChatFeedQuery(sql)) return@hook
|
||||
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)
|
||||
lastChatFeedSnapshotWrite.set(now)
|
||||
}
|
||||
}.onFailure {
|
||||
context.log.error("Failed to install chat feed cache hooks", it, "PerformanceMode")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
nativeAbis=arm64-v8a
|
||||
|
||||
APP_VERSION_NAME=1.6.2
|
||||
APP_VERSION_CODE=314
|
||||
APP_VERSION_NAME=1.6.8
|
||||
APP_VERSION_CODE=324
|
||||
debug_build_hash=18fe2a814d0e2eb5
|
||||
psIntegrityPinnedSha256=
|
||||
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user