From 4e79a22b43a83993c11063f999e72f85f4bb0702 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:10:07 +0530 Subject: [PATCH 01/20] UI Fixes in Friend feed --- common/src/main/assets/lang/en_UK.json | 2 ++ common/src/main/assets/lang/en_US.json | 2 ++ .../eternal/purrfectsnap/common/data/MessagingCoreObjects.kt | 5 +++-- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json index 36942344..8b199cb5 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -2338,6 +2338,8 @@ "unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages", "auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps", "stealth": "\ud83d\udc7b Full Stealth Mode", + "snap_stealth": "\ud83d\udcf7 Snap Stealth Mode", + "chat_stealth": "\ud83d\udcac Chat Stealth Mode", "auto_reply": "\ud83d\udce8 Auto Reply", "auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages", "mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 734b9cb5..4ba581be 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -2896,6 +2896,8 @@ "unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages", "auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps", "stealth": "\ud83d\udc7b Full Stealth Mode", + "snap_stealth": "\ud83d\udcf7 Snap Stealth Mode", + "chat_stealth": "\ud83d\udcac Chat Stealth Mode", "auto_reply": "\ud83d\udce8 Auto Reply", "auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages", "mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/MessagingCoreObjects.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/MessagingCoreObjects.kt index 70c53ec5..b9018d78 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/MessagingCoreObjects.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/data/MessagingCoreObjects.kt @@ -49,8 +49,8 @@ enum class MessagingRuleType( val configNotices: Array = emptyArray() ) { STEALTH("stealth", true, Icons.Outlined.TrackChanges), - SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = false), - CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = false), + SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = true), + CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = true), HIDE_TYPING_INDICATOR("hide_typing_indicator", true, Icons.Outlined.KeyboardHide, defaultValue = "whitelist"), AUTO_DOWNLOAD("auto_download", true, Icons.Outlined.DownloadForOffline), AUTO_SAVE("auto_save", true, Icons.Outlined.Save, defaultValue = "blacklist"), @@ -65,6 +65,7 @@ enum class MessagingRuleType( AUTO_DELETE_SENT_MESSAGES("auto_delete_sent_messages", true, Icons.Outlined.DeleteSweep, defaultValue = "blacklist"); fun translateOptionKey(optionKey: String): String { + if (key.contains("stealth")) return "features.options.friend_feed_menu_buttons.$key" return if (listMode) "rules.properties.$key.options.$optionKey" else "rules.properties.$key.name" } From dde94aeffcbf545bec2a9709dd4730c98b4c33a0 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:13:35 +0530 Subject: [PATCH 02/20] Logs filtering UI bug fix --- .../pages/themes/aphelion/AphelionLogsView.kt | 105 ++++++++++-------- .../pages/themes/legacy/LegacyTheme.kt | 99 +++++++++-------- 2 files changed, 109 insertions(+), 95 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt index 32ada622..dcfc434f 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt @@ -47,15 +47,11 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { isRefreshing = true coroutineScope.launch(Dispatchers.IO) { val readerResult = runCatching { - context.log.newReader { line -> - if (shouldHideLog(line)) return@newReader - coroutineScope.launch(Dispatchers.Main) { - visibleLogs.add(line) - } - } + context.log.newReader { /* items are batch-added from reader logic below */ } } readerResult.onFailure { context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs") + withContext(Dispatchers.Main) { isRefreshing = false } } readerResult.getOrNull()?.let { reader -> logReader = reader @@ -78,52 +74,63 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { fun LogFilterDialog() { Dialog(onDismissRequest = { showFilterDialog = false }) { PurrfectOverlayTheme { - PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - HomeLogs.LogCategory.entries.forEach { category -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { - enabledCategories.keys.forEach { enabledCategories[it] = false } - enabledCategories[category] = true - refreshLogs() + PurrfectGlassCard( + title = translation["filter_logs_title"] ?: "Log Filters", + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Color.White.copy(alpha = 0.08f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)) + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + HomeLogs.LogCategory.entries.forEach { category -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + enabledCategories[category] = !(enabledCategories[category] ?: true) + refreshLogs() + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = enabledCategories[category] == true, + onCheckedChange = { checked -> + enabledCategories[category] = checked + refreshLogs() + }, + colors = CheckboxDefaults.colors( + checkedColor = PurrfectPalette.glowPrimary, + uncheckedColor = Color.White.copy(alpha = 0.3f), + checkmarkColor = Color.White + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = translation[category.translationKey] ?: category.name, + color = Color.White, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + ) } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Checkbox( - checked = enabledCategories[category] == true, - onCheckedChange = { checked -> - enabledCategories[category] = checked - refreshLogs() - }, - colors = CheckboxDefaults.colors( - checkedColor = PurrfectPalette.glowPrimary, - uncheckedColor = Color.White.copy(alpha = 0.4f), - checkmarkColor = Color.White - ) - ) - Text( - text = translation[category.translationKey] ?: category.name, - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) + } } } - - Spacer(modifier = Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Button( - onClick = { showFilterDialog = false }, - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary) - ) { - Text(translation["filter_logs_done_button"] ?: "Done") - } + + Button( + onClick = { showFilterDialog = false }, + modifier = Modifier.fillMaxWidth().height(54.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary) + ) { + Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp) } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index a83dd93b..41f3cd64 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -1873,62 +1873,69 @@ object LegacyTheme : ThemeContract { fun LogFilterDialog() { androidx.compose.ui.window.Dialog(onDismissRequest = { showFilterDialog = false }) { me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme { - me.eternal.purrfectsnap.core.ui.PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - HomeLogs.LogCategory.entries.forEach { category -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { - // Solo Focus Logic: Tap the name to filter only this category - enabledCategories.keys.forEach { enabledCategories[it] = false } - enabledCategories[category] = true - isRefreshing = true - refreshLogs() + me.eternal.purrfectsnap.core.ui.PurrfectGlassCard( + title = translation["filter_logs_title"] ?: "Log Filters", + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = Color.White.copy(alpha = 0.08f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)) + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + HomeLogs.LogCategory.entries.forEach { category -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + enabledCategories[category] = !(enabledCategories[category] ?: true) + refreshLogs() + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = enabledCategories[category] == true, + onCheckedChange = { checked -> + enabledCategories[category] = checked + refreshLogs() + }, + colors = CheckboxDefaults.colors( + checkedColor = PurrfectPalette.glowPrimary, + uncheckedColor = Color.White.copy(alpha = 0.3f), + checkmarkColor = Color.White + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = translation[category.translationKey] ?: category.name, + color = Color.White, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + ) } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Checkbox( - checked = enabledCategories[category] == true, - onCheckedChange = { checked -> - enabledCategories[category] = checked - isRefreshing = true - refreshLogs() - }, - colors = CheckboxDefaults.colors( - checkedColor = PurrfectPalette.glowPrimary, - uncheckedColor = Color.White.copy(alpha = 0.4f), - checkmarkColor = Color.White - ) - ) - Text( - text = translation[category.translationKey] ?: category.name, - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.Medium - ) + } } } - Spacer(modifier = Modifier.height(8.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Button( - onClick = { showFilterDialog = false }, - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary) - ) { - Text(translation["filter_logs_done_button"] ?: "Done") - } + Button( + onClick = { showFilterDialog = false }, + modifier = Modifier.fillMaxWidth().height(54.dp), + shape = RoundedCornerShape(18.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary) + ) { + Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp) } } } } } } - if (showFilterDialog) { LogFilterDialog() } From fa5c7e07719c4d596d5d6277e7f406ff0ba13135 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:18:56 +0530 Subject: [PATCH 03/20] Hardened bridge stability with 15s idle timeout --- .../core/util/media/HttpServer.kt | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/util/media/HttpServer.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/util/media/HttpServer.kt index c42a3c6f..23b0cabb 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/util/media/HttpServer.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/util/media/HttpServer.kt @@ -12,11 +12,12 @@ import java.net.SocketException import java.util.Locale import java.util.StringTokenizer import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.random.Random class HttpServer( - private val timeout: Int = 10000 + private val timeout: Int = 15000 // Optimized: 15s Middle Ground ) { private fun newRandomPort() = Random.nextInt(10000, 65535) @@ -53,18 +54,24 @@ class HttpServer( AbstractLogger.directDebug("Starting http server on port $port") for (i in 0..5) { try { - serverSocket = ServerSocket(port) + serverSocket = ServerSocket(port).apply { + soTimeout = timeout + 5000 + } break } catch (e: Throwable) { AbstractLogger.directError("failed to start http server on port $port", e) port = newRandomPort() } } - continuation.resumeWith(Result.success(if (serverSocket == null) null.also { + + if (serverSocket == null) { + continuation.resume(null) return@launch - } else this@HttpServer)) + } + + continuation.resume(this@HttpServer) - while (!serverSocket!!.isClosed) { + while (isActive && serverSocket?.isClosed == false) { try { val socket = serverSocket!!.accept() timeoutJob?.cancel() @@ -77,14 +84,12 @@ class HttpServer( socketJob?.cancel() socket.close() serverSocket?.close() - }.onFailure { - AbstractLogger.directError("failed to close socket", it) } } } } catch (e: SocketException) { - AbstractLogger.directDebug("http server timed out") - break; + AbstractLogger.directDebug("http server timed out or closed") + break } catch (e: Throwable) { AbstractLogger.directError("failed to handle request", e) } @@ -96,8 +101,11 @@ class HttpServer( } fun close() { - runCatching { - serverSocket?.close() + coroutineScope.launch { + runCatching { + serverSocket?.close() + socketJob?.cancel() + } } } @@ -133,19 +141,21 @@ class HttpServer( val reader = BufferedReader(InputStreamReader(socket.getInputStream())) val outputStream = socket.getOutputStream() val writer = PrintWriter(outputStream) - val line = reader.readLine() ?: return + val line = runCatching { reader.readLine() }.getOrNull() ?: return + fun close() { runCatching { reader.close() writer.close() outputStream.close() socket.close() - }.onFailure { - AbstractLogger.directError("failed to close socket", it) } } + val parse = StringTokenizer(line) + if (!parse.hasMoreTokens()) { close(); return } val method = parse.nextToken().uppercase(Locale.getDefault()) + if (!parse.hasMoreTokens()) { close(); return } var fileRequested = parse.nextToken().lowercase(Locale.getDefault()) AbstractLogger.directDebug("[http-server:${port}] $method $fileRequested") From 61dcfd9b57ff9f8e1354c4e512ceaadf01ca94ce Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:18:57 +0530 Subject: [PATCH 04/20] Auto open notification card bug fixes --- .../impl/experiments/AutoOpenSnaps.kt | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index f413b786..1cf39601 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -54,6 +54,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN" private const val PREF_TOTAL_OPENED = "auto_open_total_opened" private const val PREF_SESSION_START = "auto_open_session_start" + private const val PREF_PROCESSED_IDS = "auto_open_processed_ids" private const val LAZY_SAVE_INTERVAL_MS = 600_000L } @@ -78,6 +79,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) } private val messaging by lazy { this@AutoOpenSnaps.context.feature(Messaging::class) } private var wakeLock: PowerManager.WakeLock? = null + private var wakeLockCooldownJob: Job? = null + + // Optimized Metadata Cache: 500 entries limit to prevent OOM crashes + private val metadataCache = Collections.synchronizedMap(object : LinkedHashMap(100, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 500 + }) private var currentStatusText = "Monitoring..." private var currentSpeedText = "Full Speed" @@ -110,7 +117,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } override fun init() { - restorePersistence() createNotificationChannels() // NATIVE HOOKS: Ensuring Snapchat never sees the app as "In Background" @@ -195,7 +201,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() val duration = System.currentTimeMillis() - startTime averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong()) - triggerLazySave(); break + break } delay((autoOpenConfig.retryDelay as PropertyValue).get().toLong()) } @@ -259,7 +265,18 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val contentType = message.messageContent?.contentType if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe + + // Prevent processing of snaps already viewed manually by the local user + if (message.messageMetadata?.openedBy?.any { it.toString() == this@AutoOpenSnaps.context.database.myUserId } == true) { + openedSnapsIds.add(clientMessageId) + return@subscribe + } + if (!canUseRule(conversationId)) return@subscribe + + val currentQueueSize = synchronized(queuedSnaps) { queuedSnaps.size } + if (currentQueueSize >= (autoOpenConfig.queueSize as PropertyValue).get()) return@subscribe + if (openedSnapsIds.contains(clientMessageId)) return@subscribe openedSnapsIds.add(clientMessageId) @@ -269,32 +286,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A synchronized(queuedSnaps) { queuedSnaps.add(item) } snapChannel.trySend(item) - acquireWakeLock(); updateStatusNotification(); triggerLazySave() + acquireWakeLock(); updateStatusNotification() } } - private fun triggerLazySave() { - needsSaving.set(true) - if (isSaving.compareAndSet(false, true)) { - this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.IO) { - while (needsSaving.get() && engineActive.get()) { - needsSaving.set(false); saveQueueToDisk(); delay(LAZY_SAVE_INTERVAL_MS) - } - isSaving.set(false) - } - } - } - - private fun saveQueueToDisk() { - prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) } - } - - private fun restorePersistence() { - val savedStartTime = prefs.getLong(PREF_SESSION_START, 0) - if (System.currentTimeMillis() - savedStartTime > 21600000) return - totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime) - } - private fun isWifiConnected(): Boolean { val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return cm.getNetworkCapabilities(cm.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true @@ -359,7 +354,11 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val isCompact = (autoOpenConfig.compactNotification as PropertyValue).get() if (isWorking) { builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)") - builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta") + if (isCompact) { + builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta") + } else { + builder.setSubText("") + } builder.setProgress(sessionTotal, processed, false) } else { builder.setContentText("$processed Opened Today │ $total Total") @@ -383,10 +382,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } val speedNotion = if (isWorking) currentSpeedText else "Idle" val speedValue = "${String.format(Locale.US, "%.1f", speed)}/s" - append("└─ Speed: $speedNotion ($speedValue)\n") + append("└─ Speed: $speedNotion ($speedValue)") if ((autoOpenConfig.showQueuePreview as PropertyValue).get()) { - append("\nQUEUE PREVIEW\n") + append("\n\nQUEUE PREVIEW\n") if (isWorking && remaining > 0) { recentSnaps.reversed().forEach { item -> append("• ${item.senderName} │ ${item.conversationType} (${item.contentType})\n") From 4bd13a9d09a6ca0d0b7e9dc77c989f2ac54b0ec2 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 20 Apr 2026 04:00:35 +0530 Subject: [PATCH 05/20] Media Downloader stability fixes --- .../purrfectsnap/download/FFMpegProcessor.kt | 1 + .../impl/downloader/MediaDownloader.kt | 126 ++++++++++++------ 2 files changed, 86 insertions(+), 41 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt index 5e4b9dd0..e35c4911 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -169,6 +169,7 @@ class FFMpegProcessor( Action.MERGE_OVERLAY -> { inputArguments += "-i" to args.overlay!!.absolutePath outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)\"" + outputArguments += "-shortest" to "" } Action.CONVERSION -> { if (ffmpegOptions.customAudioCodec.isEmpty()) { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 1663715c..3711eee2 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -155,46 +155,54 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp callback = object: DownloadCallback.Stub() { override fun onSuccess(outputFile: String) { 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 - } + modCtx.coroutineScope.launch(Dispatchers.IO) { + runCatching { + // settle delay to ensure disk flush + delay(120L) + 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").removeSuffix(".tmp") + ".mp4" - val newFile = java.io.File(newPath) - if (file.renameTo(newFile)) { - finalOutputFile = newPath - } else { - file.copyTo(newFile, overwrite = true) - file.delete() - finalOutputFile = newPath + val fileType = FileType.fromByteArray(header) + val expectedExt = fileType.fileExtension + + if (fileType != FileType.UNKNOWN && expectedExt != null && + !outputFile.endsWith(".$expectedExt", ignoreCase = true)) { + val base = outputFile.substringBeforeLast('.').takeIf { '.' in outputFile } ?: outputFile + val newPath = "$base.$expectedExt" + val newFile = java.io.File(newPath) + if (file.renameTo(newFile)) { + finalOutputFile = newPath + } else { + file.copyTo(newFile, overwrite = true) + file.delete() + finalOutputFile = newPath + } } } + }.onFailure { logError("Post-Processing Failed for $outputFile", it) } + + if (isBatch) { + batchSuccessCount.incrementAndGet() + if (downloadLogging.contains("success")) { + modCtx.inAppOverlay.showStatusToast( + icon = Icons.Outlined.DownloadDone, + text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()), + durationMs = 1300 + ) + } + return@launch } - }.onFailure { logError("Post-Processing Logic Failed for $outputFile", it) } - if (isBatch) { - batchSuccessCount.incrementAndGet() if (downloadLogging.contains("success")) { - modCtx.inAppOverlay.showStatusToast( - icon = Icons.Outlined.DownloadDone, - text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()), - durationMs = 1300 - ) + val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name) + if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300) } - return - } - - if (downloadLogging.contains("success")) { - val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name) - if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) - modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300) } } @@ -331,11 +339,28 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val totalCount = paramMap.getStorySnapTotal() modCtx.runOnUiThread { fun tryJump(retryCount: Int = 0) { + val maxRetries = 4 + val delayMs = when { + retryCount == 0 -> 180L + retryCount == 1 -> 280L + retryCount == 2 -> 400L + else -> 550L + } + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + if (synchronized(batchLock) { pendingBatchDownloadIndices } == null) return@postDelayed + val jumped = runCatching { modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) }.getOrNull() == true - if (!jumped && retryCount < 1) tryJump(retryCount + 1) - else if (!jumped) { synchronized(batchLock) { pendingBatchDownloadIndices = null }; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } - }, if (retryCount == 0) 120L else 220L) + + when { + jumped -> {} + retryCount < maxRetries -> tryJump(retryCount + 1) + else -> { + synchronized(batchLock) { pendingBatchDownloadIndices = null } + modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") + } + } + }, delayMs) } tryJump() } @@ -464,19 +489,38 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp downloadOperaMedia(provideDownloadManagerClient("${msg.clientConversationId}${msg.senderId}${msg.serverMessageId}", author.usernameForSorting!!, msg.creationTimestamp, MediaDownloadSource.CHAT_MEDIA, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) return } - paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let { - val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("userId=")?.substringBefore(",") - val author = modCtx.database.getFriendInfo(storyUserId ?: modCtx.database.myUserId) ?: return@let + paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let { playlistGroup -> + val playlistGroupString = playlistGroup.toString() + val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let { + if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null + } ?: if (playlistGroupString.contains("storyUserId=")) { + playlistGroupString.substringAfter("storyUserId=").substringBefore(",") + } else { + val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" }?.invoke(playlistGroup)?.toString()?.split(":")?.getOrNull(2) ?: return@let + val conversationMessage = modCtx.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let + val conversationParticipants = modCtx.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let + conversationParticipants.firstOrNull { it != conversationMessage.senderId } + } + + val author = modCtx.database.getFriendInfo(if (storyUserId == null || storyUserId == "null") modCtx.database.myUserId else storyUserId) ?: return@let if (!forceDownload && ((modCtx.config.downloader.preventSelfAutoDownload.get() && author.userId == modCtx.database.myUserId) || !canUseRule(author.userId!!))) return@let downloadOperaMedia(provideDownloadManagerClient(paramMap["MEDIA_ID"].toString(), author.usernameForSorting!!, null, MediaDownloadSource.STORY, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) return } val snapSource = paramMap["SNAP_SOURCE"].toString() if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || shouldAutoDownload("spotlight"))) { - downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), paramMap["CREATOR_DISPLAY_NAME"].toString(), null, MediaDownloadSource.SPOTLIGHT, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap); return + downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), (paramMap["CREATOR_DISPLAY_NAME"]?.toString() ?: "unknown").sanitizeForPath(), null, MediaDownloadSource.SPOTLIGHT, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap); return } if (!forceDownload && !shouldAutoDownload("public_stories")) return - val author = (paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername } ?: paramMap["USERNAME"]?.toString()?.substringAfter("value=")?.substringBefore(")") ?: "unknown").sanitizeForPath() + val rawAuthor = ( + paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername } + ?: paramMap["USERNAME"]?.toString()?.takeIf { it.contains("value=") }?.substringAfter("value=")?.substringBefore(")")?.substringBefore(",") + ?: paramMap["CONTEXT_USER_IDENTITY"]?.toString()?.takeIf { it.contains("username=") }?.substringAfter("username=")?.substringBefore(",") + ?: paramMap["USER_DISPLAY_NAME"]?.toString()?.takeIf { it.isNotEmpty() } + ?: paramMap["TIME_STAMP"]?.toString() + ?: "unknown" + ) + val author = rawAuthor.sanitizeForPath().replace(":", "_").replace("/", "_").replace("\\", "_").replace("?", "_").replace("*", "_").replace("\"", "_").replace("<", "_").replace(">", "_").replace("|", "_") downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), author, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) } From a18fc8ce37d0fb8c98a3260ead23ddbb1e03e637 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 20 Apr 2026 04:58:23 +0530 Subject: [PATCH 06/20] Media Downloader audio duration mapping fix --- .../me/eternal/purrfectsnap/download/FFMpegProcessor.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt index e35c4911..3a622360 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -168,7 +168,9 @@ class FFMpegProcessor( } Action.MERGE_OVERLAY -> { inputArguments += "-i" to args.overlay!!.absolutePath - outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)\"" + outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)[vout]\"" + outputArguments += "-map" to "\"[vout]\"" + outputArguments += "-map" to "\"0:a?\"" outputArguments += "-shortest" to "" } Action.CONVERSION -> { From a20496329c500a098ec172f6a4a2e6c561fd386b Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 20 Apr 2026 05:38:23 +0530 Subject: [PATCH 07/20] Downloader allow duplicate and ffmpeg shortest sync fixes --- .../download/DownloadProcessor.kt | 4 +- .../purrfectsnap/download/FFMpegProcessor.kt | 5 +- .../impl/downloader/MediaDownloader.kt | 30 ++++++---- .../impl/experiments/AutoOpenSnaps.kt | 55 ++++++++++++------- 4 files changed, 56 insertions(+), 38 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt index dd0dc591..b4520b63 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt @@ -283,7 +283,7 @@ class DownloadProcessor ( while (true) { val existingFile = outputFileFolder.findFile(finalFileName) ?: break - if (existingFile.length() == inputFile.length()) { + if (existingFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get()) { val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri) if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) { return GallerySaveResult(existingFile.uri, alreadyDownloaded = true) @@ -376,7 +376,7 @@ class DownloadProcessor ( var destFile = File(destDir, fileName) var suffix = 1 while (destFile.exists()) { - if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) { + if (destFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) { return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true) } destFile = File(destDir, appendNameSuffix(fileName, suffix++)) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt index 3a622360..4778968b 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -168,10 +168,7 @@ class FFMpegProcessor( } Action.MERGE_OVERLAY -> { inputArguments += "-i" to args.overlay!!.absolutePath - outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)[vout]\"" - outputArguments += "-map" to "\"[vout]\"" - outputArguments += "-map" to "\"0:a?\"" - outputArguments += "-shortest" to "" + outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" } Action.CONVERSION -> { if (ffmpegOptions.customAudioCodec.isEmpty()) { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 3711eee2..65d4401e 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -189,19 +189,23 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp if (isBatch) { batchSuccessCount.incrementAndGet() if (downloadLogging.contains("success")) { - modCtx.inAppOverlay.showStatusToast( - icon = Icons.Outlined.DownloadDone, - text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()), - durationMs = 1300 - ) + modCtx.runOnUiThread { + modCtx.inAppOverlay.showStatusToast( + icon = Icons.Outlined.DownloadDone, + text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()), + durationMs = 1300 + ) + } } return@launch } if (downloadLogging.contains("success")) { val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name) - if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) - modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300) + modCtx.runOnUiThread { + if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300) + } } } } @@ -209,16 +213,20 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp override fun onProgress(message: String) { if (isBatch || !downloadLogging.contains("progress")) return val toastText = message.ifBlank { translations["download_started_toast"] ?: "Started" } - if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) - modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300) + modCtx.runOnUiThread { + if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300) + } } override fun onFailure(message: String, throwable: String?) { if (!downloadLogging.contains("failure")) return val errorText = translations[if (message == "Failed to download") "failed_generic_toast" else message] ?: message if (isBatch) { batchFailureCount.incrementAndGet(); return } - if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText) - modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300) + modCtx.runOnUiThread { + if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300) + } } } ) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index 1cf39601..4f45efe4 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -54,7 +54,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN" private const val PREF_TOTAL_OPENED = "auto_open_total_opened" private const val PREF_SESSION_START = "auto_open_session_start" - private const val PREF_PROCESSED_IDS = "auto_open_processed_ids" private const val LAZY_SAVE_INTERVAL_MS = 600_000L } @@ -79,12 +78,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) } private val messaging by lazy { this@AutoOpenSnaps.context.feature(Messaging::class) } private var wakeLock: PowerManager.WakeLock? = null - private var wakeLockCooldownJob: Job? = null - - // Optimized Metadata Cache: 500 entries limit to prevent OOM crashes - private val metadataCache = Collections.synchronizedMap(object : LinkedHashMap(100, 0.75f, true) { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 500 - }) private var currentStatusText = "Monitoring..." private var currentSpeedText = "Full Speed" @@ -117,6 +110,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } override fun init() { + restorePersistence() createNotificationChannels() // NATIVE HOOKS: Ensuring Snapchat never sees the app as "In Background" @@ -127,6 +121,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val state = param.arg(0).toString() if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null) } + // INDUSTRIAL FIX: Restoring the universal v1.6.8 background wake-up hook + hookConstructor(HookStage.AFTER) { param -> + methods.firstOrNull { it.name == "appStateChanged" }?.let { method -> + val enumClass = method.parameterTypes[0] + val activeState = enumClass.enumConstants?.firstOrNull { it.toString() == "ACTIVE" || it.toString() == "FOREGROUND" } + if (activeState != null) method.invoke(param.thisObject(), activeState) + } + } } findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply { hook("onAppForegrounded", HookStage.BEFORE) { param -> param.setResult(null) } @@ -201,7 +203,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() val duration = System.currentTimeMillis() - startTime averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong()) - break + triggerLazySave(); break } delay((autoOpenConfig.retryDelay as PropertyValue).get().toLong()) } @@ -265,18 +267,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val contentType = message.messageContent?.contentType if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe - - // Prevent processing of snaps already viewed manually by the local user - if (message.messageMetadata?.openedBy?.any { it.toString() == this@AutoOpenSnaps.context.database.myUserId } == true) { - openedSnapsIds.add(clientMessageId) - return@subscribe - } - if (!canUseRule(conversationId)) return@subscribe - - val currentQueueSize = synchronized(queuedSnaps) { queuedSnaps.size } - if (currentQueueSize >= (autoOpenConfig.queueSize as PropertyValue).get()) return@subscribe - if (openedSnapsIds.contains(clientMessageId)) return@subscribe openedSnapsIds.add(clientMessageId) @@ -286,10 +277,32 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A synchronized(queuedSnaps) { queuedSnaps.add(item) } snapChannel.trySend(item) - acquireWakeLock(); updateStatusNotification() + acquireWakeLock(); updateStatusNotification(); triggerLazySave() } } + private fun triggerLazySave() { + needsSaving.set(true) + if (isSaving.compareAndSet(false, true)) { + this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.IO) { + while (needsSaving.get() && engineActive.get()) { + needsSaving.set(false); saveQueueToDisk(); delay(LAZY_SAVE_INTERVAL_MS) + } + isSaving.set(false) + } + } + } + + private fun saveQueueToDisk() { + prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) } + } + + private fun restorePersistence() { + val savedStartTime = prefs.getLong(PREF_SESSION_START, 0) + if (System.currentTimeMillis() - savedStartTime > 21600000) return + totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime) + } + private fun isWifiConnected(): Boolean { val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return cm.getNetworkCapabilities(cm.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true @@ -452,4 +465,4 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" } } -data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String, val timestamp: Long = System.currentTimeMillis()) +data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String) From 335cc00dfb2fa368a890d0af4c0cdbfeeb883c86 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:47:58 +0530 Subject: [PATCH 08/20] Unlimited Local pins bug fix --- .../impl/experiments/AutoOpenSnaps.kt | 19 ++-- .../core/features/impl/ui/PinConversations.kt | 91 ++++++++++++++++--- 2 files changed, 87 insertions(+), 23 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index 4f45efe4..fc0f1d29 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -1,6 +1,5 @@ package me.eternal.purrfectsnap.core.features.impl.experiments -import android.app.ActivityManager import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -15,7 +14,6 @@ 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.channels.Channel import kotlinx.coroutines.sync.Mutex @@ -42,7 +40,7 @@ import kotlin.random.Random /** * AutoOpenSnaps: High-performance engine with real-time diagnostics. - * Optimized for 20+ snaps/s with accurate stats and background resilience. + * Optimized for background resilience and industrial stability. */ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) { companion object { @@ -99,7 +97,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val now = System.currentTimeMillis(); val window = 5000L synchronized(snapTimestamps) { snapTimestamps.removeIf { now - it > window } - // Smoother calculation for high-frequency bursts return if (snapTimestamps.isEmpty()) 0.0 else (snapTimestamps.size.toDouble() / (window / 1000.0)) } } @@ -113,7 +110,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A restorePersistence() createNotificationChannels() - // NATIVE HOOKS: Ensuring Snapchat never sees the app as "In Background" if ((autoOpenConfig.allowRunningInBackground as PropertyValue).get()) { runCatching { findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply { @@ -121,7 +117,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val state = param.arg(0).toString() if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null) } - // INDUSTRIAL FIX: Restoring the universal v1.6.8 background wake-up hook hookConstructor(HookStage.AFTER) { param -> methods.firstOrNull { it.name == "appStateChanged" }?.let { method -> val enumClass = method.parameterTypes[0] @@ -158,7 +153,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A continue } - // SPEED OPTIMIZATION: Instant switch (40ms) when stealth is off val isSafe = (autoOpenConfig.safeProcessing as PropertyValue).get() if (lastConversationId != null && lastConversationId != item.conversationId) { delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue).get().toLong() else 40L) @@ -198,7 +192,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A success = withContext(Dispatchers.IO) { performOpen(item) } if (success) { - // IMPORTANT: Item only removed after successful processing to ensure Stats sync synchronized(queuedSnaps) { queuedSnaps.remove(item) } sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() val duration = System.currentTimeMillis() - startTime @@ -305,7 +298,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun isWifiConnected(): Boolean { val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - return cm.getNetworkCapabilities(cm.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + + // RESILIENT WIFI CHECK: Iterates through all networks to find ANY WiFi transport (VPN aware) + return cm.allNetworks.any { network -> + cm.getNetworkCapabilities(network)?.let { caps -> + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + } == true + } } private fun isDeviceIdle(): Boolean = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode @@ -355,7 +355,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status") .setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY) - // ICON LOGIC: Pause, Monitoring (Sync), or Active (Play) val iconRes = when { isPaused.get() -> android.R.drawable.ic_media_pause !isWorking -> android.R.drawable.ic_popup_sync diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/PinConversations.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/PinConversations.kt index 03e946ca..b54971ee 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/PinConversations.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/PinConversations.kt @@ -7,47 +7,112 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.Hooker 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.util.ktx.setObjectField import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID +import me.eternal.purrfectsnap.mapper.impl.CallbackMapper +import java.util.Collections class PinConversations : MessagingRuleFeature("PinConversations", MessagingRuleType.PIN_CONVERSATION) { + companion object { + // 3-year offset for persistent local conversation sorting + private const val PIN_OFFSET = 100000000000L + } + + private fun forcePinsInFeed(entries: ArrayList) { + val now = System.currentTimeMillis() + // Capture stable timestamp once to prevent jitter during the sweep + val stableTimestamp = now + PIN_OFFSET + + entries.forEach { entry -> + val conversationIdObject = entry.getObjectFieldOrNull("mConversationId") ?: return@forEach + runCatching { + val conversationUUID = SnapUUID(conversationIdObject) + if (getState(conversationUUID.toString())) { + // Apply identical timestamp lead to all pinned items + entry.setObjectField("mPinnedTimestampMs", stableTimestamp) + } else { + // Reset timestamp if it's currently a "Future" timestamp but shouldn't be pinned + val currentTs = entry.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L + if (currentTs > now + (PIN_OFFSET / 2)) { + entry.setObjectField("mPinnedTimestampMs", now) + } + } + } + } + + // Manual sort to ensure stable UI transition and prevent list jumping + runCatching { + Collections.sort(entries) { a, b -> + val tsA = a.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L + val tsB = b.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L + tsB.compareTo(tsA) + } + } + } + override fun init() { if (!context.config.messaging.unlimitedConversationPinning.get()) return + // Intercept native pinning requests and bypass server-side limits context.classCache.feedManager.hook("setPinnedConversationStatus", HookStage.BEFORE) { param -> val conversationUUID = SnapUUID(param.arg(0)) val isPinned = param.arg(1).toString() == "PINNED" setState(conversationUUID.toString(), isPinned) + + // Callback forcing to suppress "Can't pin conversation" errors for both PIN and UNPIN val callback = param.arg(2) mutableSetOf<() -> Unit>().apply { - addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onSuccess", HookStage.BEFORE) { + addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback, "onSuccess", HookStage.BEFORE) { forEach { it() } }) - addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onError", HookStage.BEFORE) { methodParam -> + addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback, "onError", HookStage.BEFORE) { methodParam -> methodParam.setResult(null) + // Manually trigger success to bypass server-side limit rejections callback::class.java.getDeclaredMethod("onSuccess").invoke(callback) }) } } - context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> - val instance = param.thisObject() - val conversationUUID = SnapUUID(instance.getObjectField("mConversationId")) - if (getState(conversationUUID.toString())) { - instance.setObjectField("mPinnedTimestampMs", 1L) + // Active feed sweep to ensure pinned conversations remain at the top + context.mappings.useMapper(CallbackMapper::class) { + val callbackMap = callbacks.getAsMap().orEmpty() + callbackMap.entries.forEach { (_, className) -> + val clazz = runCatching { findClass(className!!) }.getOrNull() ?: return@forEach + clazz.methods.forEach { method -> + if (method.name.startsWith("on") && method.name.endsWith("Complete") && method.parameterTypes.any { it == ArrayList::class.java }) { + clazz.hook(method.name, HookStage.BEFORE) { param -> + (param.args().firstOrNull { it is ArrayList<*> } as? ArrayList)?.let { forcePinsInFeed(it) } + } + } + } } } + // Apply pinning lead to newly created conversation objects + context.classCache.conversation.hookConstructor(HookStage.AFTER) { param -> + val instance = param.thisObject() + val conversationIdObject = instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor + runCatching { + val conversationUUID = SnapUUID(conversationIdObject) + if (getState(conversationUUID.toString())) { + instance.setObjectField("mPinnedTimestampMs", System.currentTimeMillis() + PIN_OFFSET) + } + } + } + + // Apply pinning lead to newly created feed entry objects context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param -> val instance = param.thisObject() - val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor) - val isPinned = getState(conversationUUID.toString()) - if (isPinned) { - instance.setObjectField("mPinnedTimestampMs", 1L) + val conversationIdObject = instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor + runCatching { + val conversationUUID = SnapUUID(conversationIdObject) + if (getState(conversationUUID.toString())) { + instance.setObjectField("mPinnedTimestampMs", System.currentTimeMillis() + PIN_OFFSET) + } } } } override fun getRuleState() = RuleState.WHITELIST -} \ No newline at end of file +} From fb94a2e8a3b195a701dfc8d927507c87d0ed5bdb Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:20:52 +0530 Subject: [PATCH 09/20] Continous Send bug fixes --- common/src/main/assets/lang/ar_AE.json | 3 +- common/src/main/assets/lang/en_US.json | 3 +- .../common/config/impl/UserInterfaceTweaks.kt | 2 +- .../features/impl/ui/MessageIndicators.kt | 140 ++++++++++++------ 4 files changed, 98 insertions(+), 50 deletions(-) diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 77c0e964..37e6fdb5 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -2570,7 +2570,8 @@ "platform_indicator": "يضيف أيقونة المنصة التي تم إرسال الوسيط منها (مثل Android، iOS، Web)", "location_indicator": "يضيف أيقونة \ud83d\udccd للـ snaps عندما يتم إرسالها مع تمكين الموقع", "ovf_editor_indicator": "يشير إلى ما إذا كان snap قد تم إرساله باستخدام محرر OVF", - "director_mode_indicator": "يضيف أيقونة \u270f\ufe0f للـ snaps عندما يتم إرسالها باستخدام وضع المخرج، والذي يمكن استخدامه لإرسال صور المعرض كـ snaps" + "director_mode_indicator": "يضيف أيقونة \u270f\ufe0f للـ snaps عندما يتم إرسالها باستخدام وضع المخرج، والذي يمكن استخدامه لإرسال صور المعرض كـ snaps", + "memories_indicator": "إضافة رمز \uD83D\uDCD6 للسنابات التي تم إعادة إرسالها من الذكريات بدلاً من التقاطها بالكاميرا الحية" }, "auto_mark_as_read": { "conversation_read": "وضع علامة مقروء على المحادثة عند إرسال رسالة", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 4ba581be..04b0a2c9 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -3259,7 +3259,8 @@ "platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)", "location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled", "ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor", - "director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps" + "director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps", + "memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera" }, "auto_mark_as_read": { "conversation_read": "Mark conversation as read when sending a message", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index d2004be2..550652b8 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -57,7 +57,7 @@ class UserInterfaceTweaks : ConfigContainer() { val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } - val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator") { requireRestart() } + val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator") { requireRestart() } val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() } val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/MessageIndicators.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/MessageIndicators.kt index a78b3f9a..3bb9911f 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/MessageIndicators.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/MessageIndicators.kt @@ -8,23 +8,69 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.Text -import androidx.compose.runtime.getValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.ui.createComposeView -import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent import me.eternal.purrfectsnap.core.features.Feature import me.eternal.purrfectsnap.core.ui.AppleLogo import kotlin.random.Random +@Composable +private fun GradientIcon( + imageVector: ImageVector, + brush: Brush, + size: androidx.compose.ui.unit.Dp = 16.dp +) { + Image( + imageVector = imageVector, + contentDescription = null, + colorFilter = ColorFilter.tint(Color.White), + modifier = Modifier + .size(size) + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect(brush = brush, blendMode = BlendMode.SrcAtop) + } + ) +} + +@Composable +private fun GradientText( + text: String, + brush: Brush, + fontWeight: FontWeight, + fontSize: androidx.compose.ui.unit.TextUnit +) { + Text( + text = text, + color = Color.White, + fontWeight = fontWeight, + fontSize = fontSize, + modifier = Modifier + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect(brush = brush, blendMode = BlendMode.SrcAtop) + } + ) +} + class MessageIndicators : Feature("Message Indicators") { override fun init() { val messageIndicatorsConfig = context.config.userInterface.messageIndicators.getNullable() ?: return @@ -35,96 +81,96 @@ class MessageIndicators : Feature("Message Indicators") { val appleLogo = AppleLogo context.event.subscribe(BindViewEvent::class) { event -> - event.chatMessage { _, _ -> + event.chatMessage { conversationId, _ -> val view = event.view as? ViewGroup ?: return@subscribe view.findViewWithTag(messageInfoTag)?.let { view.removeView(it) } val message = event.databaseMessage ?: return@chatMessage if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage + if (message.senderId == context.database.myUserId) return@chatMessage val reader = ProtoReader(message.messageContent ?: return@chatMessage) + val isGroupConversation = (context.database.getConversationParticipants(conversationId)?.size ?: 0) > 2 createComposeView(event.view.context) { + val lockBrush = Brush.linearGradient(listOf(Color(0xFF4CD471), Color(0xFF00B8D9))) + val locationBrush = Brush.linearGradient(listOf(Color(0xFFFF4B5C), Color(0xFFFFB74D))) + val androidBrush = Brush.linearGradient(listOf(Color(0xFF3DDC84), Color(0xFFA5E635))) + val appleBrush = Brush.linearGradient(listOf(Color(0xFFFFFFFF), Color(0xFF94A3B8))) + val webBrush = Brush.linearGradient(listOf(Color(0xFF4FC3F7), Color(0xFF00E5FF))) + val directorBrush = Brush.linearGradient(listOf(Color(0xFFFFB74D), Color(0xFFFF5E3A))) + val ovfBrush = Brush.linearGradient(listOf(Color(0xFFFF5CA8), Color(0xFFA64CFF))) + val memoriesBrush = Brush.linearGradient(listOf(Color(0xFFFFE066), Color(0xFFFFB300))) + Box( modifier = Modifier .fillMaxWidth() .height(50.dp) - .padding(top = 4.dp, end = 1.dp), - contentAlignment = Alignment.TopEnd + .padding(top = 6.dp), + contentAlignment = Alignment.TopCenter ) { - val hasEncryption by rememberAsyncMutableState(defaultValue = false) { + val hasEncryption = remember(reader, isGroupConversation) { if (reader.containsPath(4, 4, 1, 1) || reader.containsPath(4, 4, 1, 1, 1) || reader.getByteArray(4, 3, 3) != null || reader.containsPath(3, 99, 3)) { - return@rememberAsyncMutableState true + return@remember true } - if (!reader.containsPath(3, 99, 5, 1)) return@rememberAsyncMutableState false - reader.containsPath(4, 5, 1, 3, 1) - || reader.getVarInt(4, 5, 1, 3, 2, 9) == 1L + if (isGroupConversation) return@remember false + if (reader.containsPath(4, 5, 1, 3, 1)) return@remember true + reader.getVarInt(4, 5, 1, 3, 2, 9) in setOf(1L, 3L) } - val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) { + val sentFromIosDevice = remember(reader) { if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null } - val sentFromWebApp by rememberAsyncMutableState(defaultValue = false) { + val sentFromWebApp = remember(reader) { reader.getVarInt(4, 4, *(if (reader.containsPath(4, 4, 3)) intArrayOf(3, 3, 22, 1) else intArrayOf(11, 22, 1))) == 7L } - val sentWithLocation by rememberAsyncMutableState(defaultValue = false) { + val sentWithLocation = remember(reader) { reader.getVarInt(4, 4, 11, 17, 5) != null } - val sentUsingOvfEditor by rememberAsyncMutableState(defaultValue = false) { + val sentUsingOvfEditor = remember(reader) { (reader.getString(4, 4, 11, 12, 1) ?: reader.getString(4, 4, 11, 13, 4, 1, 2, 12, 20, 1)) == "c13129f7-fe4a-44c4-9b9d-e0b26fee8f82" } - val sentUsingDirectorMode by rememberAsyncMutableState(defaultValue = false) { + val sentUsingDirectorMode = remember(reader) { reader.followPath(4, 4, 11, 28)?.let { (it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L) } == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null } + val sentFromMemories = remember(reader) { + reader.getVarInt(4, 18) != null + || reader.getString(4, 5, 1, 2)?.contains("/h/") == true + } Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { if (sentWithLocation && messageIndicatorsConfig.contains("location_indicator")) { - Image( - imageVector = Icons.Default.LocationOn, - colorFilter = ColorFilter.tint(Color.Green), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) + GradientIcon(Icons.Default.LocationOn, locationBrush) } if (messageIndicatorsConfig.contains("platform_indicator")) { - Image( - imageVector = when { - sentFromWebApp -> Icons.Default.Laptop - sentFromIosDevice -> appleLogo - else -> Icons.Default.Android - }, - colorFilter = ColorFilter.tint(Color.Green), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) + val (platformIcon, platformBrush) = when { + sentFromWebApp -> Icons.Default.Laptop to webBrush + sentFromIosDevice -> appleLogo to appleBrush + else -> Icons.Default.Android to androidBrush + } + GradientIcon(platformIcon, platformBrush) } if (hasEncryption && messageIndicatorsConfig.contains("encryption_indicator")) { - Image( - imageVector = Icons.Default.Lock, - colorFilter = ColorFilter.tint(Color.Green), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) + GradientIcon(Icons.Default.Lock, lockBrush) } if (sentUsingDirectorMode && messageIndicatorsConfig.contains("director_mode_indicator")) { - Image( - imageVector = Icons.Default.Edit, - colorFilter = ColorFilter.tint(Color.Red), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) + GradientIcon(Icons.Default.Edit, directorBrush) + } + if (sentFromMemories && messageIndicatorsConfig.contains("memories_indicator")) { + GradientIcon(Icons.Default.HistoryEdu, memoriesBrush) } if (sentUsingOvfEditor && messageIndicatorsConfig.contains("ovf_editor_indicator")) { - Text( + GradientText( text = "OVF", - color = Color.Red, + brush = ovfBrush, fontWeight = FontWeight.ExtraBold, - fontSize = 10.sp, + fontSize = 11.sp ) } } From 2be1c3258d7d10dce5d2aca863033d532e79a7a0 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:21:13 +0530 Subject: [PATCH 10/20] Message indicator bug fix --- common/src/main/assets/lang/en_UK.json | 5 +++- common/src/main/assets/lang/en_US.json | 4 ++- common/src/main/assets/lang/hi_IN.json | 5 +++- .../common/config/impl/UserInterfaceTweaks.kt | 2 +- .../features/impl/ui/MessageIndicators.kt | 28 ++++++++++--------- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/common/src/main/assets/lang/en_UK.json b/common/src/main/assets/lang/en_UK.json index 8b199cb5..c57d1486 100644 --- a/common/src/main/assets/lang/en_UK.json +++ b/common/src/main/assets/lang/en_UK.json @@ -2667,7 +2667,10 @@ "platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)", "location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled", "ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor", - "director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps" + "director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps", + "memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera", + "skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64", + "disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65" }, "auto_mark_as_read": { "conversation_read": "Mark conversation as read when sending a message", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 04b0a2c9..2b7149d3 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -3260,7 +3260,9 @@ "location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled", "ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor", "director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps", - "memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera" + "memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera", + "skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64", + "disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65" }, "auto_mark_as_read": { "conversation_read": "Mark conversation as read when sending a message", diff --git a/common/src/main/assets/lang/hi_IN.json b/common/src/main/assets/lang/hi_IN.json index ac72530d..4e9f3886 100644 --- a/common/src/main/assets/lang/hi_IN.json +++ b/common/src/main/assets/lang/hi_IN.json @@ -2544,7 +2544,10 @@ "platform_indicator": "वह प्लेटफ़ॉर्म आइकन जोड़ता है जहाँ से मीडिया भेजा गया था (उदा. Android, iOS, Web)", "location_indicator": "Snaps में \ud83d\udccd आइकन जोड़ता है जब उन्हें लोकेशन सक्षम के साथ भेजा गया हो", "ovf_editor_indicator": "इंगित करता है कि क्या कोई Snap OVF एडिटर का उपयोग करके भेजा गया है", - "director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है" + "director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है", + "memories_indicator": "मेमोरीज़ से फिर से भेजे गए Snaps में \ud83d\udcd6 आइकन जोड़ता है", + "skip_own_indicators": "अपने स्वयं के भेजे गए Snaps पर संकेतक आइकन छुपाता है \ud83d\udc64", + "disable_indicators_in_groups": "UI अव्यवस्था को कम करने के लिए समूह वार्तालापों में सभी संकेतकों को अक्षम करता है \ud83d\udc65" }, "auto_mark_as_read": { "conversation_read": "संदेश भेजते समय वार्तालाप को पढ़े गए के रूप में चिह्नित करें", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index 550652b8..e5256be7 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -57,7 +57,7 @@ class UserInterfaceTweaks : ConfigContainer() { val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } - val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator") { requireRestart() } + val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator", "skip_own_indicators", "disable_indicators_in_groups") { requireRestart() } val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() } val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/MessageIndicators.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/MessageIndicators.kt index 3bb9911f..e5521879 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/MessageIndicators.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/MessageIndicators.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -24,6 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.ui.createComposeView +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent import me.eternal.purrfectsnap.core.features.Feature @@ -87,9 +89,10 @@ class MessageIndicators : Feature("Message Indicators") { val message = event.databaseMessage ?: return@chatMessage if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage - if (message.senderId == context.database.myUserId) return@chatMessage + if (message.senderId == context.database.myUserId && messageIndicatorsConfig.contains("skip_own_indicators")) return@chatMessage val reader = ProtoReader(message.messageContent ?: return@chatMessage) val isGroupConversation = (context.database.getConversationParticipants(conversationId)?.size ?: 0) > 2 + if (isGroupConversation && messageIndicatorsConfig.contains("disable_indicators_in_groups")) return@chatMessage createComposeView(event.view.context) { val lockBrush = Brush.linearGradient(listOf(Color(0xFF4CD471), Color(0xFF00B8D9))) @@ -105,38 +108,37 @@ class MessageIndicators : Feature("Message Indicators") { modifier = Modifier .fillMaxWidth() .height(50.dp) - .padding(top = 6.dp), - contentAlignment = Alignment.TopCenter + .padding(top = 6.dp, end = 6.dp), + contentAlignment = Alignment.TopEnd ) { - val hasEncryption = remember(reader, isGroupConversation) { + val hasEncryption by rememberAsyncMutableState(defaultValue = false) { if (reader.containsPath(4, 4, 1, 1) || reader.containsPath(4, 4, 1, 1, 1) || reader.getByteArray(4, 3, 3) != null || reader.containsPath(3, 99, 3)) { - return@remember true + return@rememberAsyncMutableState true } - if (isGroupConversation) return@remember false - if (reader.containsPath(4, 5, 1, 3, 1)) return@remember true + if (reader.containsPath(4, 5, 1, 3, 1)) return@rememberAsyncMutableState true reader.getVarInt(4, 5, 1, 3, 2, 9) in setOf(1L, 3L) } - val sentFromIosDevice = remember(reader) { + val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) { if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null } - val sentFromWebApp = remember(reader) { + val sentFromWebApp by rememberAsyncMutableState(defaultValue = false) { reader.getVarInt(4, 4, *(if (reader.containsPath(4, 4, 3)) intArrayOf(3, 3, 22, 1) else intArrayOf(11, 22, 1))) == 7L } - val sentWithLocation = remember(reader) { + val sentWithLocation by rememberAsyncMutableState(defaultValue = false) { reader.getVarInt(4, 4, 11, 17, 5) != null } - val sentUsingOvfEditor = remember(reader) { + val sentUsingOvfEditor by rememberAsyncMutableState(defaultValue = false) { (reader.getString(4, 4, 11, 12, 1) ?: reader.getString(4, 4, 11, 13, 4, 1, 2, 12, 20, 1)) == "c13129f7-fe4a-44c4-9b9d-e0b26fee8f82" } - val sentUsingDirectorMode = remember(reader) { + val sentUsingDirectorMode by rememberAsyncMutableState(defaultValue = false) { reader.followPath(4, 4, 11, 28)?.let { (it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L) } == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null } - val sentFromMemories = remember(reader) { + val sentFromMemories by rememberAsyncMutableState(defaultValue = false) { reader.getVarInt(4, 18) != null || reader.getString(4, 5, 1, 2)?.contains("/h/") == true } From 0e549a1565fb727911177ad2a42d76f4196875a2 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:02:26 +0530 Subject: [PATCH 11/20] Revert: Performance Mode PR #126 --- .../common/config/ConfigContainer.kt | 5 +- .../features/impl/messaging/SendOverride.kt | 223 +------ .../features/impl/tweaks/PerformanceMode.kt | 584 +++--------------- 3 files changed, 119 insertions(+), 693 deletions(-) diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigContainer.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigContainer.kt index aeb87e87..4848d287 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigContainer.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigContainer.kt @@ -1,7 +1,6 @@ package me.eternal.purrfectsnap.common.config import android.content.Context -import com.google.gson.JsonNull import com.google.gson.JsonObject import me.eternal.purrfectsnap.common.logger.AbstractLogger import kotlin.reflect.KProperty @@ -80,9 +79,7 @@ open class ConfigContainer( properties.forEach { (propertyKey, propertyValue) -> if (!exportSensitiveData && propertyKey.params.flags.contains(ConfigFlag.SENSITIVE)) return@forEach if (!includeSavedLocations && propertyKey.dataType.type == DataProcessors.Type.MAP_COORDINATES) return@forEach - val serializedValue = propertyValue.getRaw()?.let { - propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations) - } ?: JsonNull.INSTANCE + val serializedValue = propertyValue.getRaw()?.let { propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations) } json.add(propertyKey.name, serializedValue) } return json diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt index 3a092b38..68734871 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt @@ -1,6 +1,5 @@ package me.eternal.purrfectsnap.core.features.impl.messaging -import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build @@ -65,37 +64,15 @@ import kotlin.time.toDuration class SendOverride : Feature("Send Override") { companion object { private const val NOTIFICATION_CHANNEL_ID = "scheduled_send" - private const val CONTINUOUS_SEND_CHANNEL_ID = "continuous_send_status" - private const val STATUS_NOTIFICATION_ID = 54322 - private const val COMPLETION_NOTIFICATION_ID = 54323 - - const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.CONTINUOUS_SEND_PAUSE_RESUME" - const val ACTION_STOP = "me.eternal.purrfectsnap.CONTINUOUS_SEND_STOP" - private val internalMultipartSend = ThreadLocal.withInitial { false } private var queuedOriginalItemRepeatCount = 0 private var queuedOriginalItemRepeatOverrideType: String? = null private var queuedOriginalItemRepeatSnapDurationMs: Int? = null - - // Notification & Loop Tracking - private var totalRepeatCount = 0 - private var processedRepeatCount = 0 - private var currentRecipientName: String = "Unknown" - private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false) - private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false) - // State persistence for background operations - private var lastCapturedDestinationsObj: Any? = null - private var lastCapturedMessageContentJson: String? = null - private var lastCapturedOriginalCallback: Any? = null - - private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?, destinations: Any, contentJson: String, originalCallback: Any?) { + private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) { queuedOriginalItemRepeatCount = repeatCount queuedOriginalItemRepeatOverrideType = overrideType queuedOriginalItemRepeatSnapDurationMs = snapDurationMs - lastCapturedDestinationsObj = destinations - lastCapturedMessageContentJson = contentJson - lastCapturedOriginalCallback = originalCallback MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs) } @@ -103,68 +80,29 @@ class SendOverride : Feature("Send Override") { queuedOriginalItemRepeatCount = 0 queuedOriginalItemRepeatOverrideType = null queuedOriginalItemRepeatSnapDurationMs = null - totalRepeatCount = 0 - processedRepeatCount = 0 - isPaused.set(false) - isStopped.set(false) - lastCapturedDestinationsObj = null - lastCapturedMessageContentJson = null - lastCapturedOriginalCallback = null - } - } - - private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true) - - private fun updateContinuousSendNotification() { - if (!engineActive.get() || isStopped.get()) return - - val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) - val remaining = queuedOriginalItemRepeatCount - val processed = processedRepeatCount - val total = totalRepeatCount - val isWorking = (remaining > 0 || (total > 0 && processed < total)) && !isStopped.get() - - if (!isWorking) { - notificationManager.cancel(STATUS_NOTIFICATION_ID) - showCompletionNotification(processed, total) - return } - val progressPercent = if (total > 0) (processed * 100) / total else 0 - - val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play) - .setContentTitle("Sending Snaps to $currentRecipientName") - .setContentText("Progress: $processed / $total ($progressPercent%)") - .setSubText("$processed / $total") - .setProgress(total, processed, false) + private fun handleQueuedOriginalItemRepeatSuccess(): Boolean { + if (queuedOriginalItemRepeatCount <= 0) { + clearQueuedOriginalItemRepeats() + return false + } - val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause" - builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(ACTION_PAUSE_RESUME)).build()) - builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP)).build()) + val overrideType = queuedOriginalItemRepeatOverrideType ?: run { + clearQueuedOriginalItemRepeats() + return false + } + val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs - notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) - } - - private fun showCompletionNotification(sent: Int, total: Int) { - val title = if (isStopped.get()) "Continuous Send Stopped" else "Continuous Send Finished" - val content = "Successfully sent $sent / $total snaps to $currentRecipientName" - - val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) - val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle(title) - .setContentText(content) - .setAutoCancel(true) - - notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build()) - } - - private fun createPendingIntent(action: String): android.app.PendingIntent { - val intent = android.content.Intent(action).setPackage(context.androidContext.packageName) - return android.app.PendingIntent.getBroadcast(context.androidContext, action.hashCode(), intent, android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE) + queuedOriginalItemRepeatCount-- + MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs) + val result = MediaFilePicker.sendReusableOriginalItem() + if (!result) { + queuedOriginalItemRepeatCount++ + clearQueuedOriginalItemRepeats() + } + return result + } } private var selectedType by mutableStateOf("SNAP") @@ -258,33 +196,6 @@ class SendOverride : Feature("Send Override") { @OptIn(ExperimentalLayoutApi::class) override fun init() { createNotificationChannel() - - val actionReceiver = object : android.content.BroadcastReceiver() { - override fun onReceive(ctx: android.content.Context?, intent: android.content.Intent?) { - when (intent?.action) { - ACTION_PAUSE_RESUME -> { - isPaused.set(!isPaused.get()) - updateContinuousSendNotification() - } - ACTION_STOP -> { - isStopped.set(true) - if (isPaused.get()) { - isPaused.set(false) - } - updateContinuousSendNotification() - } - } - } - } - val filter = android.content.IntentFilter().apply { - addAction(ACTION_PAUSE_RESUME) - addAction(ACTION_STOP) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.androidContext.registerReceiver(actionReceiver, filter, android.content.Context.RECEIVER_NOT_EXPORTED) - } else { - context.androidContext.registerReceiver(actionReceiver, filter) - } val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get() var postSavePolicy: Int? = null @@ -798,13 +709,13 @@ class SendOverride : Feature("Send Override") { } } - fun invokeSendManually(destinations: MessageDestinations, messageContent: MessageContent, callback: Any?) { + fun invokeSendManually(messageContent: MessageContent, callback: Any?) { val conversationManager = conversationManagerInstance ?: error("ConversationManager is null") internalMultipartSend.set(true) try { sendMessageWithContentMethod.invoke( conversationManager, - cloneDestinations(destinations), + cloneDestinations(event.destinations), messageContent.instanceNonNull(), callback ) @@ -814,7 +725,6 @@ class SendOverride : Feature("Send Override") { } fun sendMediaManual( - destinations: MessageDestinations, sourceMessageContent: MessageContent, overrideType: String, snapDurationMs: Int?, @@ -874,7 +784,7 @@ class SendOverride : Feature("Send Override") { .build() } - invokeSendManually(destinations, partContent, callback) + invokeSendManually(partContent, callback) } sendPart(0) @@ -884,12 +794,11 @@ class SendOverride : Feature("Send Override") { postSavePolicy = null val targetReader = ProtoReader(sourceMessageContent.content ?: return false) if (!applyOverride(sourceMessageContent, targetReader, overrideType, snapDurationMs)) return false - invokeSendManually(destinations, sourceMessageContent, completionCallback) + invokeSendManually(sourceMessageContent, completionCallback) return true } fun sendRepeatedMediaManual( - destinations: MessageDestinations, repeatCount: Int, overrideType: String, snapDurationMs: Int? @@ -911,7 +820,7 @@ class SendOverride : Feature("Send Override") { } val preparedContent = createMessageContentFromOriginal() - if (!sendMediaManual(destinations, preparedContent, overrideType, snapDurationMs, callback)) { + if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) { invokeCallbackError(originalCallback, "Failed to send") } } @@ -934,76 +843,12 @@ class SendOverride : Feature("Send Override") { 10000 } - fun handleQueuedOriginalItemRepeatSuccess(convId: String): Boolean { - if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) { - val processed = processedRepeatCount - val total = totalRepeatCount - clearQueuedOriginalItemRepeats() - context.runOnUiThread { - val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) - notificationManager.cancel(STATUS_NOTIFICATION_ID) - showCompletionNotification(processed, total) - } - return false - } - - val destinations = lastCapturedDestinationsObj as? MessageDestinations ?: return false - val contentJson = lastCapturedMessageContentJson ?: return false - val originalCb = lastCapturedOriginalCallback - val overrideType = queuedOriginalItemRepeatOverrideType ?: "SNAP" - val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs - - context.coroutineScope.launch { - while (isPaused.get() && !isStopped.get()) { - delay(500) - } - if (isStopped.get()) { - context.runOnUiThread { handleQueuedOriginalItemRepeatSuccess(convId) } - return@launch - } - - delay(1000) - - context.runOnUiThread { - queuedOriginalItemRepeatCount-- - processedRepeatCount++ - updateContinuousSendNotification() - - val repeatedContent = createMessageContentFromOriginal() - - val callback = CallbackBuilder(sendMessageCallbackClass) - .override("onSuccess") { - context.runOnUiThread { - if (!handleQueuedOriginalItemRepeatSuccess(convId)) { - runCatching { - originalCb?.javaClass?.methods?.firstOrNull { it.name == "onSuccess" }?.invoke(originalCb) - } - } - } - } - .override("onError", shouldUnhook = false) { - val error = it.argNullable(0) - runCatching { - originalCb?.javaClass?.methods?.firstOrNull { it.name == "onError" && it.parameterCount == 1 }?.invoke(originalCb, error) - } - clearQueuedOriginalItemRepeats() - val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) - notificationManager.cancel(STATUS_NOTIFICATION_ID) - } - .build() - - sendMediaManual(destinations, repeatedContent, overrideType, snapDurationMs, callback) - } - } - return true - } - fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) { sendEvent.addCallbackResult("onSuccess") { context.runOnUiThread { val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess() val handledRepeat = if (!handledSplit) { - handleQueuedOriginalItemRepeatSuccess(conversationIds.first()) + handleQueuedOriginalItemRepeatSuccess() } else { false } @@ -1529,7 +1374,6 @@ class SendOverride : Feature("Send Override") { context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100) if (sendRepeatedMediaManual( - MessageDestinations(cloneDestinations(event.destinations)), repeatCount, finalSelectedType, selectedSnapDurationMs @@ -1585,19 +1429,7 @@ class SendOverride : Feature("Send Override") { invokeOriginalAndRestoreResult(event) } } else if (MediaFilePicker.hasReusableOriginalItem()) { - totalRepeatCount = repeatCount - processedRepeatCount = 1 - currentRecipientName = recipientNameForTask - updateContinuousSendNotification() - - queueOriginalItemRepeats( - repeatCount - 1, - finalSelectedType, - selectedSnapDurationMs, - MessageDestinations(cloneDestinations(event.destinations)), - originalMessageJson, - originalCallback - ) + queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs) attachQueuedRepeatCallbacks(event) if (sendMedia(finalSelectedType, selectedSnapDurationMs)) { invokeOriginalAndRestoreResult(event) @@ -1606,7 +1438,6 @@ class SendOverride : Feature("Send Override") { } } else { sendRepeatedMediaManual( - MessageDestinations(cloneDestinations(event.destinations)), repeatCount, finalSelectedType, selectedSnapDurationMs diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt index 71b43ff2..5f798a23 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt @@ -2,85 +2,31 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks import android.animation.ValueAnimator import android.app.Activity -import android.app.Dialog -import android.content.Context -import android.database.Cursor -import android.database.MatrixCursor import android.database.sqlite.SQLiteDatabase +import android.hardware.camera2.CaptureRequest import android.media.MediaRecorder +import android.os.Build +import android.transition.Transition import android.os.HandlerThread import android.os.Process -import android.util.Base64 +import android.util.Range import android.view.View +import android.view.ViewPropertyAnimator +import android.view.animation.Animation import android.widget.OverScroller import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager -import java.io.File import java.lang.Thread -import java.lang.reflect.Method -import java.util.LinkedHashMap import java.util.concurrent.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 CHAT_FEED_CACHE_MAX_AGE_MS = 5L * 60L * 1000L - private const val CHAT_FEED_CACHE_SCHEMA_VERSION = 2 - private const val MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS = 64 - private const val MESSAGE_WINDOW_STATE_MAX_AGE_MS = 7L * 24L * 60L * 60L * 1000L - private const val SNAP_PREFETCH_GROUP_MESSAGES = 48 - 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, - val rows: List>, - ) - - private data class ChatFeedSnapshotCache( - val schemaVersion: Int, - val queryKey: String, - val createdAt: Long, - val snapshot: CursorSnapshot, - ) - - private data class MessageWindowState( - val conversationId: String, - val currentSize: Int, - 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" @@ -90,7 +36,6 @@ class PerformanceMode : Feature("Performance Mode") { Process.THREAD_PRIORITY_MORE_FAVORABLE } val minimumFrameRate = if (isMaxProfile) 60 else 45 - val minimumRecordingFrameRate = if (isMaxProfile) 30 else 24 val durationScale = if (isMaxProfile) 0.35f else 0.55f val recyclerViewCacheSize = if (isMaxProfile) 64 else 32 val maxRequests = if (isMaxProfile) 192 else 96 @@ -98,16 +43,11 @@ class PerformanceMode : Feature("Performance Mode") { val minimumCoreThreads = if (isMaxProfile) 16 else 8 val prefetchItemCount = if (isMaxProfile) 24 else 12 val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L - val maxScrollDurationMs = if (isMaxProfile) 72 else 180 + val maxScrollDurationMs = if (isMaxProfile) 120 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", + "Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate", "PerformanceMode" ) @@ -131,217 +71,28 @@ class PerformanceMode : Feature("Performance Mode") { val executorLog = firstHitLogger("ThreadPoolExecutor.constructor") val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor") val animatorLog = firstHitLogger("ValueAnimator.getDurationScale") + val animatorDurationLog = firstHitLogger("ValueAnimator.setDuration") + val viewAnimatorDurationLog = firstHitLogger("ViewPropertyAnimator.setDuration") + val transitionDurationLog = firstHitLogger("Transition.setDuration") + val animationDurationLog = firstHitLogger("Animation.setDuration") val recyclerCtorLog = firstHitLogger("RecyclerView.constructor") val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter") val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager") val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase") val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase") val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate") + val captureRequestLog = firstHitLogger("CaptureRequest.Builder.set") + val sustainedModeLog = firstHitLogger("Window.setSustainedPerformanceMode") + val refreshRateLog = firstHitLogger("Activity.preferredRefreshRate") val overScrollerLog = firstHitLogger("OverScroller.startScroll") - val 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 { + return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk").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) - - fun invalidateChatFeedSnapshot(reason: String) { - val deleted = runCatching { - if (!chatFeedSnapshotFile.exists()) return@runCatching false - chatFeedSnapshotFile.delete() - }.getOrDefault(false) - chatFeedSnapshotServedThisProcess.set(false) - if (deleted) { - context.log.info("Invalidated chat feed snapshot ($reason)", "PerformanceMode") - } - } - - Activity::class.java.hook("onResume", HookStage.AFTER) { - if (!isMaxProfile) return@hook - chatFeedSnapshotServedThisProcess.set(false) - } - - val windowStatePrefs = context.androidContext.getSharedPreferences("purrfectsnap_perf_message_windows", Context.MODE_PRIVATE) - val messageWindowStates = runCatching { - val raw = windowStatePrefs.getString("states", null).orEmpty() - if (raw.isBlank()) { - LinkedHashMap() - } else { - context.gson.fromJson>( - raw, - object : TypeToken>() {}.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") - } - } - - val snapshotQueryWhitespaceRegex = Regex("\\s+") - fun buildChatFeedSnapshotQueryKey(sql: String): String { - return sql.lowercase() - .replace(snapshotQueryWhitespaceRegex, " ") - .trim() - } - - fun isChatFeedQuery(sql: String): Boolean { - val normalized = buildChatFeedSnapshotQueryKey(sql) - if (!normalized.startsWith("select ")) return false - - val isFriendsFeedViewQuery = - normalized.startsWith("select * from friendsfeedview ") && - normalized.contains(" order by _id ") && - normalized.contains(" limit ") - - val isFeedEntryQuery = - normalized.startsWith("select * from feed_entry ") && - normalized.contains(" order by last_updated_timestamp desc ") && - normalized.contains(" limit ") - - return (isFriendsFeedViewQuery || isFeedEntryQuery) && - !normalized.contains("count(") && - !normalized.contains("select 0") && - !normalized.contains("where key = ?") && - !normalized.contains("where client_conversation_id = ?") - } - - fun cursorCell(cursor: Cursor, index: Int): SnapshotCell { - 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 originalPosition = cursor.position - val snapshot = runCatching { - val columns = cursor.columnNames.toList() - val rows = mutableListOf>() - if (cursor.moveToFirst()) { - var rowCount = 0 - do { - rows += columns.indices.map { index -> cursorCell(cursor, index) } - rowCount++ - } while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext()) - } - CursorSnapshot(columns, rows) - }.onFailure { - context.log.error("Failed to snapshot chat feed cursor", it, "PerformanceMode") - }.getOrNull() - - runCatching { cursor.moveToPosition(originalPosition) } - val restoredPosition = runCatching { cursor.position }.getOrNull() - if (restoredPosition != originalPosition) { - context.log.warn( - "Skipping chat feed snapshot write due non-restorable cursor position (from=$originalPosition to=${restoredPosition ?: "unknown"})", - "PerformanceMode" - ) - return null - } - return snapshot - } - - 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, expectedQueryKey: String): CursorSnapshot? { - return runCatching { - if (!file.exists()) return null - val cache = context.gson.fromJson(file.readText(Charsets.UTF_8), ChatFeedSnapshotCache::class.java) ?: return null - if (cache.schemaVersion != CHAT_FEED_CACHE_SCHEMA_VERSION) { - runCatching { file.delete() } - return null - } - if (cache.queryKey != expectedQueryKey) { - runCatching { file.delete() } - return null - } - if (System.currentTimeMillis() - cache.createdAt > CHAT_FEED_CACHE_MAX_AGE_MS) { - runCatching { file.delete() } - return null - } - cache.snapshot - }.getOrElse { - runCatching { file.delete() } - null - } - } - - fun writeSnapshot(file: File, queryKey: String, snapshot: CursorSnapshot) { - runCatching { - file.writeText( - context.gson.toJson( - ChatFeedSnapshotCache( - schemaVersion = CHAT_FEED_CACHE_SCHEMA_VERSION, - queryKey = queryKey, - createdAt = System.currentTimeMillis(), - snapshot = snapshot, - ) - ), - Charsets.UTF_8 - ) - }.onFailure { - context.log.error("Failed to persist friend list snapshot", it, "PerformanceMode") - } - } - - context.event.subscribe(NetworkApiRequestEvent::class) { event -> - if (!isMaxProfile) return@subscribe - val url = event.url - if (url.contains("ami/friends")) { - invalidateChatFeedSnapshot("friends-mutation-sync") - } - 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(0) @@ -366,12 +117,9 @@ class PerformanceMode : Feature("Performance Mode") { val thread = param.thisObject() if (!isPerformanceSensitiveThread(thread.name)) return@hook runCatching { - thread.priority = preferredJavaThreadPriority + thread.priority = Thread.MAX_PRIORITY } threadStartLog("name=${thread.name} priority=${thread.priority}") - if ((thread.name ?: "").contains("map", ignoreCase = true) || (thread.name ?: "").contains("mapbox", ignoreCase = true)) { - mapThreadLog("name=${thread.name} priority=${thread.priority}") - } } ThreadPoolExecutor::class.java.hookConstructor(HookStage.AFTER) { param -> @@ -401,10 +149,47 @@ class PerformanceMode : Feature("Performance Mode") { animatorLog("durationScale=$durationScale") } + ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param -> + val original = param.arg(0) + val updated = original.coerceAtMost(maxAnimationDurationMs) + if (updated != original) { + param.setArg(0, updated) + } + animatorDurationLog("requested=$original applied=${param.arg(0)}") + } + + ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param -> + val original = param.arg(0) + val updated = original.coerceAtMost(maxAnimationDurationMs) + if (updated != original) { + param.setArg(0, updated) + } + viewAnimatorDurationLog("requested=$original applied=${param.arg(0)}") + } + + Transition::class.java.hook("setDuration", HookStage.BEFORE) { param -> + val original = param.arg(0) + val updated = original.coerceAtMost(maxAnimationDurationMs) + if (updated != original) { + param.setArg(0, updated) + } + transitionDurationLog("requested=$original applied=${param.arg(0)}") + } + + Animation::class.java.hook("setDuration", HookStage.BEFORE) { param -> + val original = param.arg(0) + val updated = original.coerceAtMost(maxAnimationDurationMs) + if (updated != original) { + param.setArg(0, updated) + } + animationDurationLog("requested=$original applied=${param.arg(0)}") + } + RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param -> val recyclerView = param.thisObject() recyclerView.setItemViewCacheSize(recyclerViewCacheSize) recyclerView.overScrollMode = View.OVER_SCROLL_NEVER + recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) if (isMaxProfile) { recyclerView.itemAnimator = null } @@ -414,6 +199,7 @@ class PerformanceMode : Feature("Performance Mode") { RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param -> val recyclerView = param.thisObject() recyclerView.setItemViewCacheSize(recyclerViewCacheSize) + recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) if (isMaxProfile) { recyclerView.itemAnimator = null } @@ -433,6 +219,7 @@ class PerformanceMode : Feature("Performance Mode") { layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS } } + recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount") } @@ -461,11 +248,8 @@ class PerformanceMode : Feature("Performance Mode") { MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param -> val currentRate = param.arg(0) - val applied = currentRate - .coerceAtLeast(minimumRecordingFrameRate) - .coerceAtMost(if (isMaxProfile) 60 else 45) - if (applied != currentRate) { - param.setArg(0, applied) + if (currentRate < minimumFrameRate) { + param.setArg(0, minimumFrameRate) } mediaRecorderLog("requested=$currentRate applied=${param.arg(0)}") } @@ -481,234 +265,48 @@ class PerformanceMode : Feature("Performance Mode") { } } - OverScroller::class.java.hook("fling", HookStage.BEFORE) { param -> - if (param.args().size >= 10) { - val overX = param.arg(8) - val overY = param.arg(9) - if (overX != 0) param.setArg(8, 0) - if (overY != 0) param.setArg(9, 0) + CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param -> + val key = param.arg>(0) + when (key) { + CaptureRequest.EDGE_MODE -> param.setArg(1, CaptureRequest.EDGE_MODE_FAST) + CaptureRequest.NOISE_REDUCTION_MODE -> param.setArg(1, CaptureRequest.NOISE_REDUCTION_MODE_FAST) + CaptureRequest.HOT_PIXEL_MODE -> param.setArg(1, CaptureRequest.HOT_PIXEL_MODE_FAST) + CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE -> param.setArg(1, CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE_FAST) + CaptureRequest.CONTROL_AF_MODE -> param.setArg(1, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE -> param.setArg(1, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF) + CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE -> { + val currentRange = param.argNullable(1) as? Range<*> + val lower = (currentRange?.lower as? Int) ?: minimumFrameRate + val upper = (currentRange?.upper as? Int) ?: minimumFrameRate + if (upper < minimumFrameRate) { + param.setArg(1, Range(lower.coerceAtMost(minimumFrameRate), minimumFrameRate)) + } + } } + captureRequestLog("key=${key.name} value=${param.argNullable(1)}") } fun applyActivityPerformanceTuning(activity: Activity) { runCatching { - activity.window.setWindowAnimations(0) + activity.window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + val display = activity.display + val targetRefreshRate = display?.supportedModes?.maxByOrNull { it.refreshRate }?.refreshRate + ?.coerceAtLeast(preferredRefreshRate) ?: preferredRefreshRate + activity.window.attributes = activity.window.attributes.apply { + this.preferredRefreshRate = targetRefreshRate + } + refreshRateLog("activity=${activity::class.java.name} refreshRate=$targetRefreshRate") + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) { + runCatching { + activity.window.setSustainedPerformanceMode(true) + sustainedModeLog("activity=${activity::class.java.name}") + } } } onNextActivityCreate { applyActivityPerformanceTuning(it) } - - Dialog::class.java.hook("show", HookStage.AFTER) { param -> - val dialog = param.nullableThisObject() 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() 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(0) - val applied = requested.coerceAtLeast(120) - if (applied != requested) { - param.setArg(0, applied) - } - mapRendererFpsLog("requested=$requested applied=${param.arg(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() - 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(5) - val applied = clampPositiveDuration(original, snapMapCameraDurationMs) - if (applied != original) { - param.setArg(5, applied) - } - runCatching { nativeCancelTransitions?.invoke(param.thisObject()) } - mapCameraAnimLog("requested=$original applied=${param.arg(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(2) - val applied = clampPositiveDuration(original, snapMapMoveDurationMs) - if (applied != original) { - param.setArg(2, applied) - } - runCatching { nativeCancelTransitions?.invoke(param.thisObject()) } - mapMoveLog("requested=$original applied=${param.arg(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(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(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(1) ?: return@hook - if (!isChatFeedQuery(sql)) return@hook - if (chatFeedSnapshotServedThisProcess.get()) return@hook - val queryKey = buildChatFeedSnapshotQueryKey(sql) - readSnapshot(chatFeedSnapshotFile, queryKey)?.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(1) ?: return@hook - if (!isChatFeedQuery(sql)) 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 queryKey = buildChatFeedSnapshotQueryKey(sql) - val snapshot = snapshotFromCursor(cursor) ?: return@hook - if (snapshot.rows.isEmpty()) return@hook - writeSnapshot(chatFeedSnapshotFile, queryKey, snapshot) - lastChatFeedSnapshotWrite.set(now) - } - }.onFailure { - context.log.error("Failed to install chat feed cache hooks", it, "PerformanceMode") - } - } } From c988c489e79803db0a1bfe7ad264cd822116df00 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:08:04 +0530 Subject: [PATCH 12/20] Performance Mode bug fixes --- .../purrfectsnap/common/config/impl/Global.kt | 4 +- .../impl/experiments/AutoOpenSnaps.kt | 56 +++-- .../features/impl/messaging/SendOverride.kt | 134 ++++++++++- .../features/impl/tweaks/PerformanceMode.kt | 214 +++++++++++------- 4 files changed, 301 insertions(+), 107 deletions(-) diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index d2d24343..90a87b42 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -55,9 +55,7 @@ class Global : ConfigContainer() { } } val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig()) - val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply { - profile.set("max") - } + val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() } 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() } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index 06e7c8cd..ba554ea0 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -128,6 +128,16 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } } + // Background Watchdog: Periodically refreshes UI and verifies engine health + this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.Default) { + while (isActive && engineActive.get()) { + if (autoOpenConfig.globalState == true) { + updateStatusNotification() + } + delay(5000) + } + } + setupReceivers() startEngineWorker() setupDetector() @@ -158,12 +168,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A processSnapItem(item) lastSnapProcessedAt.set(System.currentTimeMillis()) - // HIGH SPEED: 10ms floor for 20+ snaps/s + // Process at natural network speed when safety is disabled val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue).get().toLong() if (isSafe) { delay(Random.nextLong(baseDelay, baseDelay + 200)) } else { - delay(baseDelay.coerceAtMost(10)) + if (baseDelay > 0) delay(baseDelay) } if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { @@ -175,6 +185,13 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } private suspend fun processSnapItem(item: SnapQueueItem) { + // Verify database state on background thread before processing + val dbMessage = withContext(Dispatchers.IO) { this@AutoOpenSnaps.context.database.getConversationMessageFromId(item.messageId) } + if (dbMessage?.isViewedByUser == 1) { + synchronized(queuedSnaps) { queuedSnaps.remove(item) } + return + } + currentStatusText = "Active"; updateStatusNotification() var success = false val startTime = System.currentTimeMillis() @@ -186,7 +203,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A delay(1000) } - success = withContext(Dispatchers.IO) { performOpen(item) } + success = performOpen(item) if (success) { synchronized(queuedSnaps) { queuedSnaps.remove(item) } sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() @@ -200,23 +217,24 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A logError("Engine failed to open Snap: ${item.messageId}") synchronized(queuedSnaps) { queuedSnaps.remove(item) } currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification() - openedSnapsIds.remove(item.messageId) } } private suspend fun performOpen(item: SnapQueueItem): Boolean { val manager = messaging.conversationManager ?: return false - return suspendCancellableCoroutine { cont -> - runCatching { - manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> - if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) } - else if (item.serverMessageId != 0L) { - manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> - cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") - } - } else { cont.resume(false) } - } - }.onFailure { logError("Bridge Error", it); cont.resume(false) } + return withContext(Dispatchers.Main) { + suspendCancellableCoroutine { cont -> + runCatching { + manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> + if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) } + else if (item.serverMessageId != 0L) { + manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> + cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") + } + } else { cont.resume(false) } + } + }.onFailure { logError("Bridge Error", it); cont.resume(false) } + } } } @@ -250,13 +268,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe val message = event.message if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe - val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe + val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe + + val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe val serverMessageId = message.orderKey ?: 0L val contentType = message.messageContent?.contentType if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe if (!canUseRule(conversationId)) return@subscribe + + // Prevent re-queueing the same message while it is currently being processed if (openedSnapsIds.contains(clientMessageId)) return@subscribe openedSnapsIds.add(clientMessageId) @@ -295,7 +317,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun isWifiConnected(): Boolean { val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - // RESILIENT WIFI CHECK: Iterates through all networks to find ANY WiFi transport (VPN aware) + // Check for any available network with a WiFi or Ethernet transport return cm.allNetworks.any { network -> cm.getNetworkCapabilities(network)?.let { caps -> caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt index 68734871..91e82fc8 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt @@ -1,7 +1,13 @@ package me.eternal.purrfectsnap.core.features.impl.messaging +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.os.Build import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background @@ -64,11 +70,25 @@ import kotlin.time.toDuration class SendOverride : Feature("Send Override") { companion object { private const val NOTIFICATION_CHANNEL_ID = "scheduled_send" + private const val CONTINUOUS_SEND_CHANNEL_ID = "continuous_send_status" + private const val STATUS_NOTIFICATION_ID = 54322 + private const val COMPLETION_NOTIFICATION_ID = 54323 + + const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.CONTINUOUS_SEND_PAUSE_RESUME" + const val ACTION_STOP = "me.eternal.purrfectsnap.CONTINUOUS_SEND_STOP" + private val internalMultipartSend = ThreadLocal.withInitial { false } private var queuedOriginalItemRepeatCount = 0 private var queuedOriginalItemRepeatOverrideType: String? = null private var queuedOriginalItemRepeatSnapDurationMs: Int? = null + // Notification & Loop Tracking + private var totalRepeatCount = 0 + private var processedRepeatCount = 0 + private var currentRecipientName: String = "Unknown" + private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false) + private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false) + private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) { queuedOriginalItemRepeatCount = repeatCount queuedOriginalItemRepeatOverrideType = overrideType @@ -116,6 +136,80 @@ class SendOverride : Feature("Send Override") { private val backgroundHookLock = Any() private var backgroundHookRefs = 0 private var backgroundHooks: List? = null + private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true) + + private fun createContinuousNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + CONTINUOUS_SEND_CHANNEL_ID, + "Continuous Send", + NotificationManager.IMPORTANCE_LOW + ) + channel.description = "Progress status for continuous snap sending" + notificationManager.createNotificationChannel(channel) + } + } + + private fun updateContinuousSendNotification() { + if (!engineActive.get() || isStopped.get()) return + + val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) + val remaining = queuedOriginalItemRepeatCount + val processed = processedRepeatCount + val total = totalRepeatCount + val isWorking = (remaining > 0 || (total > 0 && processed < total)) && !isStopped.get() + + if (!isWorking) { + notificationManager.cancel(STATUS_NOTIFICATION_ID) + showCompletionNotification(processed, total) + return + } + + val progressPercent = if (total > 0) (processed * 100) / total else 0 + + val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSmallIcon(android.R.drawable.ic_popup_sync) // The Industrial Loop icon + .setColor(0xFF3498DB.toInt()) // Industrial Purple/Blue tint + .setContentTitle("Sending Snaps to $currentRecipientName") + .setContentText("Progress: $processed / $total ($progressPercent%)") + .setSubText("$processed / $total") + .setProgress(total, processed, false) + + val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause" + builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(ACTION_PAUSE_RESUME)).build()) + builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP)).build()) + + notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) + } + + private fun showCompletionNotification(sent: Int, total: Int) { + val title = if (isStopped.get()) "Continuous Send Stopped" else "Continuous Send Finished" + val content = "Successfully sent $sent / $total snaps to $currentRecipientName" + + val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) + val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID) + .setSmallIcon(android.R.drawable.checkbox_on_background) // Emerald Green Checkmark + .setColor(0xFF2ECC71.toInt()) // Emerald Green tint + .setContentTitle(title) + .setContentText(content) + .setAutoCancel(true) + + notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build()) + } + + private fun createPendingIntent(action: String): PendingIntent { + val intent = Intent(action).setPackage(context.androidContext.packageName) + return PendingIntent.getBroadcast( + context.androidContext, + action.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + private fun acquireScheduledSendBackground(): () -> Unit { if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {} var enableFailed = false @@ -196,7 +290,35 @@ class SendOverride : Feature("Send Override") { @OptIn(ExperimentalLayoutApi::class) override fun init() { createNotificationChannel() - + createContinuousNotificationChannel() + + val actionReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_PAUSE_RESUME -> { + isPaused.set(!isPaused.get()) + updateContinuousSendNotification() + } + ACTION_STOP -> { + isStopped.set(true) + if (isPaused.get()) { + isPaused.set(false) + } + updateContinuousSendNotification() + } + } + } + } + val filter = IntentFilter().apply { + addAction(ACTION_PAUSE_RESUME) + addAction(ACTION_STOP) + } + 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 stripMediaMetadata = context.config.messaging.stripMediaMetadata.get() var postSavePolicy: Int? = null @@ -1429,6 +1551,11 @@ class SendOverride : Feature("Send Override") { invokeOriginalAndRestoreResult(event) } } else if (MediaFilePicker.hasReusableOriginalItem()) { + totalRepeatCount = repeatCount + processedRepeatCount = 1 + currentRecipientName = recipientNameForTask + updateContinuousSendNotification() + queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs) attachQueuedRepeatCallbacks(event) if (sendMedia(finalSelectedType, selectedSnapDurationMs)) { @@ -1437,6 +1564,11 @@ class SendOverride : Feature("Send Override") { clearQueuedOriginalItemRepeats() } } else { + totalRepeatCount = repeatCount + processedRepeatCount = 0 + currentRecipientName = recipientNameForTask + updateContinuousSendNotification() + sendRepeatedMediaManual( repeatCount, finalSelectedType, diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt index 5f798a23..daedd055 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/PerformanceMode.kt @@ -2,29 +2,35 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks import android.animation.ValueAnimator import android.app.Activity +import android.app.Dialog import android.database.sqlite.SQLiteDatabase import android.hardware.camera2.CaptureRequest import android.media.MediaRecorder import android.os.Build -import android.transition.Transition import android.os.HandlerThread import android.os.Process +import android.transition.Transition import android.util.Range import android.view.View +import android.view.TextureView import android.view.ViewPropertyAnimator +import android.view.WindowManager import android.view.animation.Animation import android.widget.OverScroller import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager -import java.lang.Thread -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.ThreadPoolExecutor import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod import me.eternal.purrfectsnap.core.util.hook.hook import me.eternal.purrfectsnap.core.util.hook.hookConstructor import okhttp3.Dispatcher +import java.lang.Thread +import java.lang.reflect.Method +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.atomic.AtomicBoolean class PerformanceMode : Feature("Performance Mode") { override fun init() { @@ -43,7 +49,7 @@ class PerformanceMode : Feature("Performance Mode") { val minimumCoreThreads = if (isMaxProfile) 16 else 8 val prefetchItemCount = if (isMaxProfile) 24 else 12 val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L - val maxScrollDurationMs = if (isMaxProfile) 120 else 180 + val maxScrollDurationMs = if (isMaxProfile) 72 else 180 val preferredRefreshRate = if (isMaxProfile) 120f else 90f context.log.info( @@ -53,52 +59,33 @@ class PerformanceMode : Feature("Performance Mode") { runCatching { ValueAnimator.setFrameDelay(0L) - context.log.info("Applied ValueAnimator frame delay override: 0ms", "PerformanceMode") } - fun firstHitLogger(name: String): (String) -> Unit { - val didLog = AtomicBoolean(false) - return { details -> - if (didLog.compareAndSet(false, true)) { - context.log.info("First hit: $name | $details", "PerformanceMode") - } - } - } - - val handlerThreadConstructorLog = firstHitLogger("HandlerThread.constructor") - val handlerThreadStartLog = firstHitLogger("HandlerThread.start") - val threadStartLog = firstHitLogger("Thread.start") - val executorLog = firstHitLogger("ThreadPoolExecutor.constructor") - val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor") - val animatorLog = firstHitLogger("ValueAnimator.getDurationScale") - val animatorDurationLog = firstHitLogger("ValueAnimator.setDuration") - val viewAnimatorDurationLog = firstHitLogger("ViewPropertyAnimator.setDuration") - val transitionDurationLog = firstHitLogger("Transition.setDuration") - val animationDurationLog = firstHitLogger("Animation.setDuration") - val recyclerCtorLog = firstHitLogger("RecyclerView.constructor") - val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter") - val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager") - val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase") - val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase") - val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate") - val captureRequestLog = firstHitLogger("CaptureRequest.Builder.set") - val sustainedModeLog = firstHitLogger("Window.setSustainedPerformanceMode") - val refreshRateLog = firstHitLogger("Activity.preferredRefreshRate") - val overScrollerLog = firstHitLogger("OverScroller.startScroll") - fun isPerformanceSensitiveThread(name: String?): Boolean { val normalizedName = name?.lowercase() ?: return false - return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk").any { + return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "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) + } + + context.event.subscribe(NetworkApiRequestEvent::class) { event -> + if (!isMaxProfile) return@subscribe + val url = event.url + if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) { + event.canceled = true + } + } + HandlerThread::class.java.hookConstructor(HookStage.BEFORE) { param -> if (param.args().size < 2) return@hookConstructor val threadName = param.argNullable(0) if (!isPerformanceSensitiveThread(threadName)) return@hookConstructor param.setArg(1, threadPriority) - handlerThreadConstructorLog("name=$threadName priority=$threadPriority") } HandlerThread::class.java.hook("start", HookStage.AFTER) { param -> @@ -110,16 +97,14 @@ class PerformanceMode : Feature("Performance Mode") { 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() if (!isPerformanceSensitiveThread(thread.name)) return@hook runCatching { - thread.priority = Thread.MAX_PRIORITY + thread.priority = if (isMaxProfile) Thread.MAX_PRIORITY else Thread.NORM_PRIORITY + 1 } - threadStartLog("name=${thread.name} priority=${thread.priority}") } ThreadPoolExecutor::class.java.hookConstructor(HookStage.AFTER) { param -> @@ -131,7 +116,6 @@ class PerformanceMode : Feature("Performance Mode") { } executor.allowCoreThreadTimeOut(false) executor.prestartAllCoreThreads() - executorLog("core=${executor.corePoolSize} max=${executor.maximumPoolSize} active=${executor.activeCount}") } } @@ -140,13 +124,11 @@ class PerformanceMode : Feature("Performance Mode") { runCatching { dispatcher.maxRequests = maxRequests dispatcher.maxRequestsPerHost = maxRequestsPerHost - dispatcherLog("maxRequests=${dispatcher.maxRequests} maxRequestsPerHost=${dispatcher.maxRequestsPerHost}") } } ValueAnimator::class.java.hook("getDurationScale", HookStage.AFTER) { param -> param.setResult(durationScale) - animatorLog("durationScale=$durationScale") } ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param -> @@ -155,7 +137,6 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(0, updated) } - animatorDurationLog("requested=$original applied=${param.arg(0)}") } ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param -> @@ -164,7 +145,6 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(0, updated) } - viewAnimatorDurationLog("requested=$original applied=${param.arg(0)}") } Transition::class.java.hook("setDuration", HookStage.BEFORE) { param -> @@ -173,7 +153,6 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(0, updated) } - transitionDurationLog("requested=$original applied=${param.arg(0)}") } Animation::class.java.hook("setDuration", HookStage.BEFORE) { param -> @@ -182,28 +161,25 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(0, updated) } - animationDurationLog("requested=$original applied=${param.arg(0)}") } RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param -> val recyclerView = param.thisObject() recyclerView.setItemViewCacheSize(recyclerViewCacheSize) recyclerView.overScrollMode = View.OVER_SCROLL_NEVER - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + recyclerView.recycledViewPool.setMaxRecycledViews(0, 20) 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.setItemViewCacheSize(recyclerViewCacheSize) - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + recyclerView.recycledViewPool.setMaxRecycledViews(0, 20) if (isMaxProfile) { recyclerView.itemAnimator = null } - recyclerAdapterLog("cache=$recyclerViewCacheSize adapter=${param.argNullable(0)?.javaClass?.name}") } RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param -> @@ -212,15 +188,13 @@ class PerformanceMode : Feature("Performance Mode") { when (layoutManager) { is LinearLayoutManager -> { layoutManager.isItemPrefetchEnabled = true - layoutManager.initialPrefetchItemCount = prefetchItemCount + layoutManager.initialPrefetchItemCount = prefetchItemCount.coerceAtLeast(12) } is StaggeredGridLayoutManager -> { layoutManager.isItemPrefetchEnabled = true layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS } } - recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null) - recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount") } fun SQLiteDatabase.applyPerformancePragmas() { @@ -233,25 +207,21 @@ class PerformanceMode : Feature("Performance Mode") { } SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param -> - (param.getResult() as? SQLiteDatabase)?.also { - it.applyPerformancePragmas() - sqliteOpenLog("path=${param.argNullable(0)}") - } + (param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas() } SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param -> - (param.getResult() as? SQLiteDatabase)?.also { - it.applyPerformancePragmas() - sqliteCreateLog("path=${param.argNullable(0)}") - } + (param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas() } MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param -> val currentRate = param.arg(0) - if (currentRate < minimumFrameRate) { - param.setArg(0, minimumFrameRate) + val applied = currentRate + .coerceAtLeast(if (isMaxProfile) 30 else 24) + .coerceAtMost(if (isMaxProfile) 60 else 45) + if (applied != currentRate) { + param.setArg(0, applied) } - mediaRecorderLog("requested=$currentRate applied=${param.arg(0)}") } OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param -> @@ -261,29 +231,84 @@ class PerformanceMode : Feature("Performance Mode") { if (updated != original) { param.setArg(4, updated) } - overScrollerLog("requested=$original applied=${param.arg(4)}") } } - CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param -> - val key = param.arg>(0) - when (key) { - CaptureRequest.EDGE_MODE -> param.setArg(1, CaptureRequest.EDGE_MODE_FAST) - CaptureRequest.NOISE_REDUCTION_MODE -> param.setArg(1, CaptureRequest.NOISE_REDUCTION_MODE_FAST) - CaptureRequest.HOT_PIXEL_MODE -> param.setArg(1, CaptureRequest.HOT_PIXEL_MODE_FAST) - CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE -> param.setArg(1, CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE_FAST) - CaptureRequest.CONTROL_AF_MODE -> param.setArg(1, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) - CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE -> param.setArg(1, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF) - CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE -> { - val currentRange = param.argNullable(1) as? Range<*> - val lower = (currentRange?.lower as? Int) ?: minimumFrameRate - val upper = (currentRange?.upper as? Int) ?: minimumFrameRate - if (upper < minimumFrameRate) { - param.setArg(1, Range(lower.coerceAtMost(minimumFrameRate), minimumFrameRate)) - } + OverScroller::class.java.hook("fling", HookStage.BEFORE) { param -> + if (param.args().size >= 10) { + val overX = param.arg(8) + val overY = param.arg(9) + if (overX != 0) param.setArg(8, 0) + if (overY != 0) param.setArg(9, 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 } } - captureRequestLog("key=${key.name} value=${param.argNullable(1)}") + + 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() + runCatching { + nativeSetPrefetchTiles?.invoke(nativeMapView, true) + nativeSetPrefetchZoomDelta?.invoke(nativeMapView, 6) + nativeSetTransitionDelay?.invoke(nativeMapView, 0L) + nativeSetTransitionDuration?.invoke(nativeMapView, 0L) + nativeSetTransitionOptions?.invoke( + nativeMapView, + transitionOptionsCtor.newInstance(0L, 0L, false) + ) + nativeCancelTransitions?.invoke(nativeMapView) + } + } + + nativeMapViewClass.findRestrictedMethod { method -> + method.name == "g" && + method.parameterCount == 6 && + method.parameterTypes.last() == Long::class.javaPrimitiveType + }?.hook(HookStage.BEFORE) { param -> + val original = param.arg(5) + val applied = original.coerceAtMost(16L) + if (applied != original) { + param.setArg(5, applied) + } + runCatching { nativeCancelTransitions?.invoke(param.thisObject()) } + } + + 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(2) + val applied = original.coerceAtMost(8L) + if (applied != original) { + param.setArg(2, applied) + } + runCatching { nativeCancelTransitions?.invoke(param.thisObject()) } + } + }.onFailure { + context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode") } fun applyActivityPerformanceTuning(activity: Activity) { @@ -295,12 +320,10 @@ class PerformanceMode : Feature("Performance Mode") { activity.window.attributes = activity.window.attributes.apply { this.preferredRefreshRate = targetRefreshRate } - refreshRateLog("activity=${activity::class.java.name} refreshRate=$targetRefreshRate") } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) { runCatching { activity.window.setSustainedPerformanceMode(true) - sustainedModeLog("activity=${activity::class.java.name}") } } } @@ -308,5 +331,24 @@ class PerformanceMode : Feature("Performance Mode") { onNextActivityCreate { applyActivityPerformanceTuning(it) } + + Dialog::class.java.hook("show", HookStage.AFTER) { param -> + val dialog = param.nullableThisObject() as? Dialog ?: return@hook + val window = dialog.window ?: return@hook + runCatching { + window.setWindowAnimations(0) + window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + window.attributes = window.attributes.apply { + flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + } + } + } + + TextureView::class.java.hookConstructor(HookStage.AFTER) { param -> + val textureView = param.thisObject() + runCatching { + textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + } } } From 597871706539470fc7bc6a191163a644db880f4c Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:51:20 +0530 Subject: [PATCH 13/20] Social Tab bug fixes --- .../purrfectsnap/bridge/BridgeService.kt | 37 ++++-- .../purrfectsnap/storage/AppDatabase.kt | 8 +- .../eternal/purrfectsnap/storage/Messaging.kt | 4 +- .../manager/pages/social/AddFriendDialog.kt | 111 ++++++++++++------ .../pages/social/SocialFriendSorting.kt | 28 +++-- .../manager/pages/social/SocialRootSection.kt | 29 +++-- .../themes/aphelion/AphelionSocialView.kt | 29 ++--- .../pages/themes/legacy/LegacyTheme.kt | 29 ++--- .../purrfectsnap/bridge/BridgeInterface.aidl | 2 +- .../purrfectsnap/core/bridge/BridgeClient.kt | 31 ++--- .../impl/experiments/AutoOpenSnaps.kt | 70 +++++++---- .../features/impl/ui/HideFriendFeedEntry.kt | 14 ++- 12 files changed, 237 insertions(+), 155 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt index 83b87b4d..8cd7de09 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -219,19 +219,38 @@ class BridgeService : Service() { triggerScopeSync(SocialScope.getByName(scope), id, true) } + private val friendAccumulator = mutableListOf() + private val groupAccumulator = mutableListOf() + override fun passGroupsAndFriends( groups: List, - friends: List + friends: List, + chunkIndex: Int, + totalChunks: Int ) { - remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends") - val parsedFriends = friends.mapNotNull { toParcelable(it) } - val parsedGroups = groups.mapNotNull { toParcelable(it) } - pendingSocialSnapshotCallback?.let { callback -> - pendingSocialSnapshotCallback = null - callback(parsedFriends, parsedGroups) + if (chunkIndex == 0) { + friendAccumulator.clear() + groupAccumulator.clear() + } + + remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends") + friendAccumulator.addAll(friends.mapNotNull { toParcelable(it) }) + groupAccumulator.addAll(groups.mapNotNull { toParcelable(it) }) + + if (chunkIndex == totalChunks - 1) { + val finalFriends = friendAccumulator.toList() + val finalGroups = groupAccumulator.toList() + + pendingSocialSnapshotCallback?.let { callback -> + pendingSocialSnapshotCallback = null + callback(finalFriends, finalGroups) + } + remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups) + remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups) + + friendAccumulator.clear() + groupAccumulator.clear() } - remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups) - remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups) } override fun getScopeNotes(id: String): String? { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/AppDatabase.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/AppDatabase.kt index 95f0b131..0bb94039 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/AppDatabase.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/AppDatabase.kt @@ -5,6 +5,8 @@ import me.eternal.purrfectsnap.RemoteSideContext import me.eternal.purrfectsnap.common.data.MessagingFriendInfo import me.eternal.purrfectsnap.common.data.MessagingGroupInfo import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -15,7 +17,11 @@ class AppDatabase( val executor: ExecutorService = Executors.newSingleThreadExecutor() lateinit var database: SQLiteDatabase - var receiveMessagingDataCallback: (friends: List, groups: List) -> Unit = { _, _ -> } + // Multi-subscriber event stream for messaging data updates + val messagingDataFlow = MutableSharedFlow, List>>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) fun executeAsync(block: () -> Unit) { executor.execute { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt index 703a548f..f5272030 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt @@ -139,10 +139,10 @@ fun AppDatabase.replaceMessagingData( database.endTransaction() } - // Notify with the full updated list from the DB + // Notify all observers with the updated data from the database val allFriends = getFriends(descOrder = true) val allGroups = getGroups() - receiveMessagingDataCallback(allFriends, allGroups) + messagingDataFlow.tryEmit(allFriends to allGroups) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt index 9d8b0a36..48ec4794 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt @@ -26,9 +26,11 @@ import kotlinx.coroutines.* import me.eternal.purrfectsnap.RemoteSideContext import me.eternal.purrfectsnap.common.data.MessagingFriendInfo import me.eternal.purrfectsnap.common.data.MessagingGroupInfo +import me.eternal.purrfectsnap.common.data.MessagingRuleType import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie import me.eternal.purrfectsnap.storage.getFriends import me.eternal.purrfectsnap.storage.getGroups +import me.eternal.purrfectsnap.storage.getRuleIds import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage @@ -228,7 +230,14 @@ class AddFriendDialog( if (pinnedIds != null) { sortedBy { -pinnedIds.indexOf(it.conversationId) } } else { - this + // Priority sort for whitelisted groups + val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet() + sortedWith { a, b -> + val aSelected = whitelistedIds.contains(a.conversationId) + val bSelected = whitelistedIds.contains(b.conversationId) + if (aSelected != bSelected) if (aSelected) -1 else 1 + else a.name.compareTo(b.name, ignoreCase = true) + } } } if (friends.isNotEmpty() || groups.isNotEmpty()) { @@ -237,12 +246,7 @@ class AddFriendDialog( } } - val updateSnapshot: (List, List) -> Unit = { friends, groups -> - coroutineScope.launch { - applySnapshot(friends, groups) - } - } - + // Initial database load withContext(Dispatchers.IO) { applySnapshot( context.database.getFriends(descOrder = true), @@ -250,20 +254,11 @@ class AddFriendDialog( ) } - context.database.receiveMessagingDataCallback = updateSnapshot + // Real-time synchronization flow context.requestSocialSnapshotRefresh() - - coroutineScope.launch(Dispatchers.IO) { - repeat(25) { - delay(1000) - val dbFriends = context.database.getFriends(descOrder = true) - val dbGroups = context.database.getGroups() - if (dbFriends.isNotEmpty() || dbGroups.isNotEmpty()) { - withContext(Dispatchers.Main) { - applySnapshot(dbFriends, dbGroups) - } - return@launch - } + coroutineScope.launch { + context.database.messagingDataFlow.collect { (friends, groups) -> + applySnapshot(friends, groups) } } @@ -280,7 +275,6 @@ class AddFriendDialog( onDispose { timeoutJob?.cancel() context.bridgeService?.clearEphemeralSocialSnapshotRequest() - context.database.receiveMessagingDataCallback = { _, _ -> } } } @@ -340,6 +334,7 @@ class AddFriendDialog( it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) || it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true } ?: cachedFriends!! + val selectedFriendCount by remember(filteredFriends) { derivedStateOf { filteredFriends.count { friend -> @@ -350,6 +345,16 @@ class AddFriendDialog( val hasFriendsSelected = selectedFriendCount > 0 val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size + val selectedGroupCount by remember(filteredGroups) { + derivedStateOf { + filteredGroups.count { group -> + stateCache[group.conversationId] ?: actionHandler.getGroupState(group) + } + } + } + val hasGroupsSelected = selectedGroupCount > 0 + val allGroupsSelected = filteredGroups.isNotEmpty() && selectedGroupCount == filteredGroups.size + DialogHeader(searchKeyword) LazyColumn( @@ -359,14 +364,54 @@ class AddFriendDialog( ) { item { if (filteredGroups.isNotEmpty()) { - Text( - text = translation["category_groups"], - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, + Row( modifier = Modifier + .fillMaxWidth() .padding(bottom = 8.dp, top = 8.dp), - color = Color.White - ) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = translation["category_groups"], + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = { + coroutineScope.launch(Dispatchers.IO) { + filteredGroups.forEach { group -> + stateCache[group.conversationId] = true + actionHandler.onGroupState(group, true) + } + } + }, + enabled = !allGroupsSelected + ) { + Text( + text = context.translation["manager.dialogs.messaging_action.select_all_button"], + color = if (allGroupsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary + ) + } + TextButton( + onClick = { + coroutineScope.launch(Dispatchers.IO) { + filteredGroups.forEach { group -> + stateCache[group.conversationId] = false + actionHandler.onGroupState(group, false) + } + } + }, + enabled = hasGroupsSelected + ) { + Text( + text = translation["unselect_all_button"], + color = if (hasGroupsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f) + ) + } + } + } } } @@ -411,11 +456,7 @@ class AddFriendDialog( ) { Text( text = context.translation["manager.dialogs.messaging_action.select_all_button"], - color = if (allFriendsSelected) { - Color.White.copy(alpha = 0.45f) - } else { - PurrfectPalette.glowSecondary - } + color = if (allFriendsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary ) } TextButton( @@ -431,11 +472,7 @@ class AddFriendDialog( ) { Text( text = translation["unselect_all_button"], - color = if (hasFriendsSelected) { - PurrfectPalette.glowPrimary - } else { - Color.White.copy(alpha = 0.45f) - } + color = if (hasFriendsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f) ) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialFriendSorting.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialFriendSorting.kt index 1b2f8eaa..34865317 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialFriendSorting.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialFriendSorting.kt @@ -2,21 +2,29 @@ package me.eternal.purrfectsnap.ui.manager.pages.social import me.eternal.purrfectsnap.RemoteSideContext import me.eternal.purrfectsnap.common.data.MessagingFriendInfo +import me.eternal.purrfectsnap.storage.getFriends internal fun RemoteSideContext.sortSocialFriends( friends: List, pinnedIds: List? = null ): List { - if (config.root.userInterface.sortSocialTabByStreakLength.get()) { - return friends.sortedWith( - compareByDescending { (it.streaks?.length ?: 0) > 0 } - .thenByDescending { it.streaks?.length ?: 0 } - ) - } + val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet() + val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get() - return if (pinnedIds != null) { - friends.sortedBy { -pinnedIds.indexOf(it.userId) } - } else { - friends + return friends.sortedWith { a, b -> + val aSelected = whitelistedIds.contains(a.userId) + val bSelected = whitelistedIds.contains(b.userId) + + if (aSelected != bSelected) { + return@sortedWith if (aSelected) -1 else 1 + } + + if (sortByStreakLength) { + val aStreak = a.streaks?.length ?: 0 + val bStreak = b.streaks?.length ?: 0 + if (aStreak != bStreak) return@sortedWith bStreak.compareTo(aStreak) + } + + (a.displayName ?: a.mutableUsername).compareTo(b.displayName ?: b.mutableUsername, ignoreCase = true) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt index 891f4c66..97111f67 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt @@ -35,8 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavBackStackEntry -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import me.eternal.purrfectsnap.R import me.eternal.purrfectsnap.common.data.MessagingFriendInfo import me.eternal.purrfectsnap.common.data.MessagingGroupInfo @@ -53,10 +52,23 @@ class SocialRootSection : Routes.Route() { internal var friendList: List by mutableStateOf(emptyList()) internal var groupList: List by mutableStateOf(emptyList()) - internal fun updateScopeLists() { - context.coroutineScope.launch { - friendList = context.database.getFriends(descOrder = true) - groupList = context.database.getGroups() + @Composable + fun SocialDataController() { + LaunchedEffect(Unit) { + // Initial data fetch from the database + withContext(Dispatchers.IO) { + val dbFriends = context.database.getFriends(descOrder = true) + val dbGroups = context.database.getGroups() + friendList = context.sortSocialFriends(dbFriends) + groupList = dbGroups + } + + // Real-time synchronization from the bridge + context.requestSocialSnapshotRefresh() + context.database.messagingDataFlow.collect { (friends, groups) -> + friendList = context.sortSocialFriends(friends) + groupList = groups + } } } @@ -124,11 +136,6 @@ class SocialRootSection : Routes.Route() { addFriendDialog?.Content { addFriendDialog = null } - DisposableEffect(Unit) { - onDispose { - updateScopeLists() - } - } } FloatingActionButton( diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt index 20ad2ba2..f765b024 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSocialView.kt @@ -50,6 +50,9 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette @OptIn(ExperimentalFoundationApi::class) @Composable fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) { + // Controller handles data loading and synchronization + SocialDataController() + val titles = remember { listOf(translation["friends_tab"], translation["groups_tab"]) } @@ -58,27 +61,11 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) { var searchQuery by rememberSaveable { mutableStateOf("") } var searchActive by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(Unit) { - context.database.receiveMessagingDataCallback = { friends, groups -> - friendList = friends - groupList = groups - } - updateScopeLists() - } - DisposableEffect(Unit) { - onDispose { - context.database.receiveMessagingDataCallback = { _, _ -> } - } - } - val sortByStreakLength by produceState(initialValue = context.config.root.userInterface.sortSocialTabByStreakLength.get()) { - while (true) { - delay(300) - value = context.config.root.userInterface.sortSocialTabByStreakLength.get() - } - } val normalizedQuery = remember(searchQuery) { searchQuery.trim() } - val filteredFriends = remember(friendList, normalizedQuery, sortByStreakLength) { - val matchingFriends = if (normalizedQuery.isBlank()) { + + // Filter logic based on the parent's synchronized data lists + val filteredFriends = remember(friendList, normalizedQuery) { + if (normalizedQuery.isBlank()) { friendList } else { friendList.filter { @@ -86,8 +73,6 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) { it.displayName?.contains(normalizedQuery, ignoreCase = true) == true } } - - context.sortSocialFriends(matchingFriends) } val filteredGroups = remember(groupList, normalizedQuery) { if (normalizedQuery.isBlank()) { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index 41f3cd64..bd0b8914 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -2015,6 +2015,9 @@ object LegacyTheme : ThemeContract { } @Composable override fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) { + // Controller handles data loading and synchronization + SocialDataController() + val titles = remember { listOf(translation["friends_tab"], translation["groups_tab"]) } @@ -2023,27 +2026,11 @@ object LegacyTheme : ThemeContract { var searchQuery by rememberSaveable { mutableStateOf("") } var searchActive by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(Unit) { - context.database.receiveMessagingDataCallback = { friends, groups -> - friendList = friends - groupList = groups - } - updateScopeLists() - } - DisposableEffect(Unit) { - onDispose { - context.database.receiveMessagingDataCallback = { _, _ -> } - } - } - val sortByStreakLength by produceState(initialValue = context.config.root.userInterface.sortSocialTabByStreakLength.get()) { - while (true) { - delay(300) - value = context.config.root.userInterface.sortSocialTabByStreakLength.get() - } - } val normalizedQuery = remember(searchQuery) { searchQuery.trim() } - val filteredFriends = remember(friendList, normalizedQuery, sortByStreakLength) { - val matchingFriends = if (normalizedQuery.isBlank()) { + + // Filter logic based on the parent's synchronized data lists + val filteredFriends = remember(friendList, normalizedQuery) { + if (normalizedQuery.isBlank()) { friendList } else { friendList.filter { @@ -2051,8 +2038,6 @@ object LegacyTheme : ThemeContract { it.displayName?.contains(normalizedQuery, ignoreCase = true) == true } } - - context.sortSocialFriends(matchingFriends) } val filteredGroups = remember(groupList, normalizedQuery) { if (normalizedQuery.isBlank()) { diff --git a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl index f86ac18e..98e7dc45 100644 --- a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl +++ b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/BridgeInterface.aidl @@ -71,7 +71,7 @@ interface BridgeInterface { * @param groups list of groups (MessagingGroupInfo as parcelable) * @param friends list of friends (MessagingFriendInfo as parcelable) */ - oneway void passGroupsAndFriends(in List groups, in List friends); + oneway void passGroupsAndFriends(in List groups, in List friends, int chunkIndex, int totalChunks); @nullable String getScopeNotes(String id); diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt index 27aa62df..7ff0c1f4 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/bridge/BridgeClient.kt @@ -348,22 +348,24 @@ class BridgeClient( safeServiceCall { val serializedGroups = groups.mapNotNull { it.toSerialized() } val serializedFriends = friends.mapNotNull { it.toSerialized() } + + // Binder transaction limit is 1MB. Use 128KB chunks to avoid TransactionTooLargeException. val maxChunkBytes = 128 * 1024 - fun chunkSerialized(values: List): List> { + fun calculateParts(values: List): List> { if (values.isEmpty()) return listOf(emptyList()) val result = mutableListOf>() - val currentChunk = mutableListOf() + var currentChunk = mutableListOf() var currentSize = 0 values.forEach { value -> - val valueSize = value.toByteArray(StandardCharsets.UTF_8).size + 32 + val valueSize = value.toByteArray(Charsets.UTF_8).size + 32 if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) { result += currentChunk.toList() - currentChunk.clear() + currentChunk = mutableListOf() currentSize = 0 } - currentChunk += value + currentChunk.add(value) currentSize += valueSize } @@ -373,19 +375,18 @@ class BridgeClient( return result } - val groupChunks = chunkSerialized(serializedGroups) - val friendChunks = chunkSerialized(serializedFriends) - val chunkCount = maxOf(groupChunks.size, friendChunks.size) + val groupParts = calculateParts(serializedGroups) + val friendParts = calculateParts(serializedFriends) + val totalParts = maxOf(groupParts.size, friendParts.size) - context.log.info( - "Sending social snapshot in $chunkCount chunk(s): " + - "${serializedGroups.size} groups, ${serializedFriends.size} friends" - ) + context.log.info("Synchronizing social data in $totalParts part(s): ${serializedGroups.size} groups, ${serializedFriends.size} friends") - repeat(chunkCount) { index -> + repeat(totalParts) { index -> connectedService.passGroupsAndFriends( - groupChunks.getOrElse(index) { emptyList() }, - friendChunks.getOrElse(index) { emptyList() } + groupParts.getOrElse(index) { emptyList() }, + friendParts.getOrElse(index) { emptyList() }, + index, + totalParts ) } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index ba554ea0..2c54546d 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -52,12 +52,11 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN" private const val PREF_TOTAL_OPENED = "auto_open_total_opened" private const val PREF_SESSION_START = "auto_open_session_start" - - private const val LAZY_SAVE_INTERVAL_MS = 600_000L } private val gson = Gson() private val isPaused = AtomicBoolean(false) + private val isScreenOn = AtomicBoolean(true) private val engineActive = AtomicBoolean(true) private val totalProcessed = AtomicInteger(0) private val sessionProcessed = AtomicInteger(0) @@ -76,17 +75,18 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) } private val messaging by lazy { this@AutoOpenSnaps.context.feature(Messaging::class) } private var wakeLock: PowerManager.WakeLock? = null + private var wakeLockCooldownJob: Job? = null private var currentStatusText = "Monitoring..." private var currentSpeedText = "Full Speed" private var lastNotificationUpdate = 0L + private var lastNotificationStateHash = 0 private val notificationUpdateDelay = 1000L private val pendingNotificationUpdate = AtomicBoolean(false) private val snapTimestamps = LinkedList() private var lastConversationId: String? = null - private val isSaving = AtomicBoolean(false) - private val needsSaving = AtomicBoolean(false) + private val lastSaveTime = AtomicLong(System.currentTimeMillis()) private var isThermalThrottled = false private var lastThermalThrottleAt = 0L @@ -125,16 +125,20 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } } } + findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply { + hook("onAppForegrounded", HookStage.BEFORE) { param -> param.setResult(null) } + hook("onAppBackgrounded", HookStage.BEFORE) { param -> param.setResult(null) } + } } } - // Background Watchdog: Periodically refreshes UI and verifies engine health + // Background Watchdog: Periodically verifies engine health this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.Default) { while (isActive && engineActive.get()) { - if (autoOpenConfig.globalState == true) { + if (autoOpenConfig.globalState == true && synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) { updateStatusNotification() } - delay(5000) + delay(300000) } } @@ -179,6 +183,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { currentStatusText = "Monitoring..." updateStatusNotification() + saveQueueToDisk() // Batch complete save + startWakeLockCooldown() } } } @@ -207,9 +213,16 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A if (success) { synchronized(queuedSnaps) { queuedSnaps.remove(item) } sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() + + // Industrial Interval Check: Only write to disk once every 10 minutes during floods + if (System.currentTimeMillis() - lastSaveTime.get() > 600000) { + saveQueueToDisk() + lastSaveTime.set(System.currentTimeMillis()) + } + val duration = System.currentTimeMillis() - startTime averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong()) - triggerLazySave(); break + break } delay((autoOpenConfig.retryDelay as PropertyValue).get().toLong()) } @@ -288,19 +301,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A synchronized(queuedSnaps) { queuedSnaps.add(item) } snapChannel.trySend(item) - acquireWakeLock(); updateStatusNotification(); triggerLazySave() - } - } - - private fun triggerLazySave() { - needsSaving.set(true) - if (isSaving.compareAndSet(false, true)) { - this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.IO) { - while (needsSaving.get() && engineActive.get()) { - needsSaving.set(false); saveQueueToDisk(); delay(LAZY_SAVE_INTERVAL_MS) - } - isSaving.set(false) - } + acquireWakeLock(); updateStatusNotification(); saveQueueToDisk() } } @@ -333,12 +334,21 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } private fun acquireWakeLock() { + wakeLockCooldownJob?.cancel() if (wakeLock?.isHeld == true) return wakeLock = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { acquire(8 * 60 * 60 * 1000L) } } private fun releaseWakeLock() { if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null } + private fun startWakeLockCooldown() { + wakeLockCooldownJob?.cancel() + wakeLockCooldownJob = this@AutoOpenSnaps.context.coroutineScope.launch { + delay(30000) + releaseWakeLock() + } + } + private fun createNotificationChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notificationManager.createNotificationChannel(NotificationChannel("auto_open_status", "Auto-Open Status", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) }) @@ -347,6 +357,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun updateStatusNotification(force: Boolean = false) { val now = System.currentTimeMillis() + if (!isScreenOn.get() && !force) return if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) { if (pendingNotificationUpdate.compareAndSet(false, true)) { this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() } @@ -361,6 +372,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val processed = sessionProcessed.get() val total = totalProcessed.get() val remaining = synchronized(queuedSnaps) { queuedSnaps.size } + + // Industrial State Hashing: Prevent redundant redraws and CPU wakeups + val currentStateHash = Objects.hash(processed, total, remaining, currentStatusText, isPaused.get()) + if (currentStateHash == lastNotificationStateHash && remaining == 0) return + lastNotificationStateHash = currentStateHash + val isWorking = remaining > 0 val speed = if (isWorking) getSnapsPerSecond() else 0.0 @@ -444,6 +461,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A ACTION_PAUSE_RESUME -> { isPaused.set(!isPaused.get()); updateStatusNotification(force = true) } ACTION_CLEAR_QUEUE -> { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) } ACTION_STOP_ENGINE -> shutdownFeature() + Intent.ACTION_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) } + Intent.ACTION_SCREEN_OFF -> { isScreenOn.set(false) } Intent.ACTION_BATTERY_CHANGED -> { val temp = intent.getIntExtra("temperature", 0) / 10f if (temp >= 40f && !isThermalThrottled) { isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis() } @@ -452,7 +471,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } } } - val filter = IntentFilter().apply { addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(ACTION_STOP_ENGINE); addAction(Intent.ACTION_BATTERY_CHANGED) } + val filter = IntentFilter().apply { + addAction(ACTION_PAUSE_RESUME) + addAction(ACTION_CLEAR_QUEUE) + addAction(ACTION_STOP_ENGINE) + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + addAction(Intent.ACTION_BATTERY_CHANGED) + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter) } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/HideFriendFeedEntry.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/HideFriendFeedEntry.kt index 87dd8d32..572d1fd1 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/HideFriendFeedEntry.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/HideFriendFeedEntry.kt @@ -78,9 +78,17 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType } private fun hideBoundChatFeedRow(view: View) { - view.hideViewCompletely() - (view.parent as? View)?.hideViewCompletely() - (view.parent?.parent as? View)?.hideViewCompletely() + var current: View? = view + repeat(4) { + val parent = current?.parent as? View + // Safety: Never hide the actual list container + if (parent?.javaClass?.name?.contains("RecyclerView") == true) { + current?.hideViewCompletely() + return + } + current?.hideViewCompletely() + current = parent + } } private fun hookCallbackMethod( From d19257869b7e284b0d81782391062af33f24484b Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:40:15 +0530 Subject: [PATCH 14/20] Spoof Backup bug fixes --- .../pages/features/FeaturesRootSection.kt | 7 +- .../manager/pages/social/SocialRootSection.kt | 1 - .../purrfectsnap/common/config/impl/Spoof.kt | 3 + .../impl/experiments/AutoOpenSnaps.kt | 196 ++++++++++-------- .../impl/experiments/DeviceSpooferHook.kt | 31 ++- .../features/impl/messaging/SendOverride.kt | 24 ++- 6 files changed, 161 insertions(+), 101 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index e0d9dfa9..fea98d07 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -231,9 +231,12 @@ class FeaturesRootSection : Routes.Route() { ?: error("Failed to read randomized profile backup") val profile = RandomizedDeviceProfile.fromJson(importedJson) val generationToken = UUID.randomUUID().toString() + val profileJson = profile.toJson().toString() + + // Save to local prefs for legacy compatibility context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0) .edit() - .putString("randomized_device_profile", profile.toJson().toString()) + .putString("randomized_device_profile", profileJson) .putString("randomized_device_profile_token", generationToken) .putString("android_id", profile.androidId) .putString("advertising_id", profile.advertisingId) @@ -246,6 +249,8 @@ class FeaturesRootSection : Routes.Route() { val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile randomizeConfig.profileGenerationToken.set(generationToken) randomizeConfig.currentProfileSnapshot.set(profile.toJson().toString(2)) + randomizeConfig.profileData.set(profileJson) // Shared storage fix + context.config.writeConfig() onConfigChanged() context.shortToast("Randomized profile restored. Restart Snapchat to apply it.") diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt index 97111f67..7f81ad87 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt @@ -64,7 +64,6 @@ class SocialRootSection : Routes.Route() { } // Real-time synchronization from the bridge - context.requestSocialSnapshotRefresh() context.database.messagingDataFlow.collect { (friends, groups) -> friendList = context.sortSocialFriends(friends) groupList = groups diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Spoof.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Spoof.kt index f68710b2..8314f0de 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Spoof.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Spoof.kt @@ -186,6 +186,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) { val currentProfileSnapshot = string("current_profile_snapshot") { addFlags(ConfigFlag.HIDDEN) } + val profileData = string("profile_data") { + addFlags(ConfigFlag.HIDDEN) + } } inner class SpoofDeviceIdConfig : ConfigContainer() { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index 2c54546d..fc36dc09 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -14,10 +14,10 @@ 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.channels.Channel -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow import me.eternal.purrfectsnap.bridge.AutoOpenInterface import me.eternal.purrfectsnap.common.config.PropertyValue import me.eternal.purrfectsnap.common.data.ContentType @@ -31,6 +31,7 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.hook import me.eternal.purrfectsnap.core.util.hook.hookConstructor import java.util.* +import java.util.Objects import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -52,6 +53,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN" private const val PREF_TOTAL_OPENED = "auto_open_total_opened" private const val PREF_SESSION_START = "auto_open_session_start" + private const val PREF_SAVED_QUEUE = "auto_open_saved_queue" } private val gson = Gson() @@ -64,9 +66,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private val averageProcessingTime = AtomicLong(800) private val lastSnapProcessedAt = AtomicLong(0) - private val snapChannel = Channel(Channel.UNLIMITED) + private val snapQueue = MutableSharedFlow(extraBufferCapacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST) private val openedSnapsIds = ConcurrentHashMap.newKeySet() private val queuedSnaps = LinkedList() + private val deadLetterQueue = mutableListOf() private var engineJob: Job? = null private val engineDispatcher = Dispatchers.Default.limitedParallelism(1) @@ -85,6 +88,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private val pendingNotificationUpdate = AtomicBoolean(false) private val snapTimestamps = LinkedList() private var lastConversationId: String? = null + private var lastQueueActivity = System.currentTimeMillis() private val lastSaveTime = AtomicLong(System.currentTimeMillis()) private var isThermalThrottled = false @@ -135,10 +139,21 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A // Background Watchdog: Periodically verifies engine health this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.Default) { while (isActive && engineActive.get()) { - if (autoOpenConfig.globalState == true && synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) { - updateStatusNotification() + val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size } + if (remainingCount > 0) { + lastQueueActivity = System.currentTimeMillis(); acquireWakeLock() + if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis()) + } else { + if (!isPaused.get() && System.currentTimeMillis() - lastQueueActivity > 300000) { + val revived = synchronized(deadLetterQueue) { if (deadLetterQueue.isNotEmpty()) deadLetterQueue.removeAt(0) else null } + if (revived != null) { synchronized(queuedSnaps) { queuedSnaps.add(revived) }; snapQueue.tryEmit(System.currentTimeMillis()) } + } + if (System.currentTimeMillis() - lastQueueActivity > 300000) { + startWakeLockCooldown() + } } - delay(300000) + updateStatusNotification() + delay(30000) // 30s watchdog cycle } } @@ -149,42 +164,44 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun startEngineWorker() { engineJob = this@AutoOpenSnaps.context.coroutineScope.launch(engineDispatcher) { - while (engineActive.get()) { - val item = try { snapChannel.receive() } catch (e: Exception) { break } - - while (isPaused.get() && engineActive.get()) { - currentStatusText = "Paused"; updateStatusNotification(); delay(500) - } - if (!engineActive.get()) break + snapQueue.collect { + while (engineActive.get()) { + val item = synchronized(queuedSnaps) { if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null } ?: break - updateStatusNotification() - if (!validateEnvironmentalConstraints()) { - synchronized(queuedSnaps) { queuedSnaps.remove(item) } - continue - } + while (isPaused.get() && engineActive.get()) { + currentStatusText = "Paused"; updateStatusNotification(); delay(500) + } + if (!engineActive.get()) break - val isSafe = (autoOpenConfig.safeProcessing as PropertyValue).get() - if (lastConversationId != null && lastConversationId != item.conversationId) { - delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue).get().toLong() else 40L) - } - lastConversationId = item.conversationId - - processSnapItem(item) - lastSnapProcessedAt.set(System.currentTimeMillis()) - - // Process at natural network speed when safety is disabled - val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue).get().toLong() - if (isSafe) { - delay(Random.nextLong(baseDelay, baseDelay + 200)) - } else { - if (baseDelay > 0) delay(baseDelay) - } - - if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { - currentStatusText = "Monitoring..." updateStatusNotification() - saveQueueToDisk() // Batch complete save - startWakeLockCooldown() + if (!validateEnvironmentalConstraints()) { + synchronized(queuedSnaps) { queuedSnaps.add(0, item) } + continue + } + + val isSafe = (autoOpenConfig.safeProcessing as PropertyValue).get() + if (lastConversationId != null && lastConversationId != item.conversationId) { + delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue).get().toLong() else 40L) + } + lastConversationId = item.conversationId + + processSnapItem(item) + lastSnapProcessedAt.set(System.currentTimeMillis()) + + // Process at natural network speed when safety is disabled + val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue).get().toLong() + if (isSafe) { + delay(Random.nextLong(baseDelay, baseDelay + 200)) + } else { + if (baseDelay > 0) delay(baseDelay) + } + + if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { + currentStatusText = "Monitoring..." + updateStatusNotification() + saveQueueToDisk() // Batch complete save + startWakeLockCooldown() + } } } } @@ -194,7 +211,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A // Verify database state on background thread before processing val dbMessage = withContext(Dispatchers.IO) { this@AutoOpenSnaps.context.database.getConversationMessageFromId(item.messageId) } if (dbMessage?.isViewedByUser == 1) { - synchronized(queuedSnaps) { queuedSnaps.remove(item) } return } @@ -203,17 +219,16 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val startTime = System.currentTimeMillis() for (i in 0 until (autoOpenConfig.retryAttempts as PropertyValue).get()) { if (isPaused.get() || !engineActive.get() || autoOpenConfig.globalState == false) break - - if (messaging.conversationManager == null) { + + if (messaging.conversationManager == null) { runCatching { this@AutoOpenSnaps.context.messagingBridge.triggerSessionStart() } - delay(1000) + delay(1000) } - + success = performOpen(item) if (success) { - synchronized(queuedSnaps) { queuedSnaps.remove(item) } sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() - + // Industrial Interval Check: Only write to disk once every 10 minutes during floods if (System.currentTimeMillis() - lastSaveTime.get() > 600000) { saveQueueToDisk() @@ -228,7 +243,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } if (!success && !isPaused.get() && engineActive.get()) { logError("Engine failed to open Snap: ${item.messageId}") - synchronized(queuedSnaps) { queuedSnaps.remove(item) } + synchronized(openedSnapsIds) { openedSnapsIds.remove(item.messageId) } + synchronized(deadLetterQueue) { if (deadLetterQueue.size < 100) deadLetterQueue.add(item) else { deadLetterQueue.removeAt(0); deadLetterQueue.add(item) } } currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification() } } @@ -239,7 +255,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A suspendCancellableCoroutine { cont -> runCatching { manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> - if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) } + if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) } else if (item.serverMessageId != 0L) { manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") @@ -258,17 +274,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val isIdle = isDeviceIdle() val onlyIdle = (autoOpenConfig.onlyWhenIdle as PropertyValue).get() val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false - + val wifiStop = (autoOpenConfig.onlyOnWifi as PropertyValue).get() && !isWifi val idleStop = onlyIdle && !isIdle && !inSleepWindow - + when { wifiStop -> { currentStatusText = "Waiting for WiFi..."; delay(5000) } idleStop -> { currentStatusText = "Waiting for Idle..."; delay(5000) } - else -> { + else -> { val thermalActive = (autoOpenConfig.thermalProtection as PropertyValue).get() && isThermalThrottled currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed" - return true + return true } } updateStatusNotification() @@ -281,47 +297,58 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe val message = event.message if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe - + val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe - val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe val serverMessageId = message.orderKey ?: 0L val contentType = message.messageContent?.contentType if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe if (!canUseRule(conversationId)) return@subscribe - + // Prevent re-queueing the same message while it is currently being processed if (openedSnapsIds.contains(clientMessageId)) return@subscribe openedSnapsIds.add(clientMessageId) - + val senderId = message.senderId?.toString() ?: "unknown" val item = SnapQueueItem(conversationId, clientMessageId, serverMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType)) - + synchronized(queuedSnaps) { queuedSnaps.add(item) } - snapChannel.trySend(item) - - acquireWakeLock(); updateStatusNotification(); saveQueueToDisk() + snapQueue.tryEmit(System.currentTimeMillis()) + + acquireWakeLock(); updateStatusNotification() } } private fun saveQueueToDisk() { - prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) } + 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) - if (System.currentTimeMillis() - savedStartTime > 21600000) return + val now = System.currentTimeMillis() + if (now - savedStartTime > 21600000) return totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime) + val savedQueueJson = prefs.getString(PREF_SAVED_QUEUE, null) + if (!savedQueueJson.isNullOrBlank()) { + runCatching { + val restored: List = gson.fromJson(savedQueueJson, object : TypeToken>() {}.type) + synchronized(queuedSnaps) { queuedSnaps.addAll(restored.filter { (now - it.timestamp) < 3600000 }) } + } + } } private fun isWifiConnected(): Boolean { val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - + // Check for any available network with a WiFi or Ethernet transport return cm.allNetworks.any { network -> cm.getNetworkCapabilities(network)?.let { caps -> - caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } == true } @@ -360,7 +387,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A if (!isScreenOn.get() && !force) return if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) { if (pendingNotificationUpdate.compareAndSet(false, true)) { - this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() } + this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() } } return } @@ -372,7 +399,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val processed = sessionProcessed.get() val total = totalProcessed.get() val remaining = synchronized(queuedSnaps) { queuedSnaps.size } - + // Industrial State Hashing: Prevent redundant redraws and CPU wakeups val currentStateHash = Objects.hash(processed, total, remaining, currentStatusText, isPaused.get()) if (currentStateHash == lastNotificationStateHash && remaining == 0) return @@ -380,24 +407,19 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val isWorking = remaining > 0 val speed = if (isWorking) getSnapsPerSecond() else 0.0 - + lastNotificationUpdate = System.currentTimeMillis(); pendingNotificationUpdate.set(false) - + val sessionTotal = processed + remaining val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0 val eta = if (isWorking && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..." val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status") .setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY) - - val iconRes = when { - isPaused.get() -> android.R.drawable.ic_media_pause - !isWorking -> android.R.drawable.ic_popup_sync - else -> android.R.drawable.ic_media_play - } - builder.setSmallIcon(iconRes) + + builder.setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else if (!isWorking) android.R.drawable.ic_popup_sync else android.R.drawable.ic_media_play) builder.setContentTitle("Auto-Open: $currentStatusText") - + val isCompact = (autoOpenConfig.compactNotification as PropertyValue).get() if (isWorking) { builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)") @@ -471,28 +493,28 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } } } - val filter = IntentFilter().apply { + val filter = IntentFilter().apply { addAction(ACTION_PAUSE_RESUME) addAction(ACTION_CLEAR_QUEUE) addAction(ACTION_STOP_ENGINE) addAction(Intent.ACTION_SCREEN_ON) addAction(Intent.ACTION_SCREEN_OFF) - addAction(Intent.ACTION_BATTERY_CHANGED) + addAction(Intent.ACTION_BATTERY_CHANGED) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter) } private fun recordSpeedTimestamp() { synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 250) snapTimestamps.removeFirst() } } - - private fun shutdownFeature() { + + private fun shutdownFeature() { engineActive.set(false) - snapChannel.close() engineJob?.cancel() releaseWakeLock() - cancelStatusNotification() + cancelStatusNotification() + saveQueueToDisk() } - + private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID) fun getInterface(): AutoOpenInterface { @@ -505,7 +527,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun getSenderDisplayName(userId: String): String = this@AutoOpenSnaps.context.database.getFriendInfo(userId)?.displayName ?: "Unknown" private fun getConversationType(convId: String, senderId: String): String = if (this@AutoOpenSnaps.context.database.getDMOtherParticipant(convId) != null) "Friend DM" else this@AutoOpenSnaps.context.database.getFeedEntryByConversationId(convId)?.feedDisplayName ?: "Group Chat" - private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" } + private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" } } -data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String) +data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String, val timestamp: Long = System.currentTimeMillis()) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpooferHook.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpooferHook.kt index 00665201..71465d08 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpooferHook.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/DeviceSpooferHook.kt @@ -144,8 +144,24 @@ class DeviceSpooferHook : Feature("Device Spoofer") { } private fun getRandomizedProfile(): RandomizedDeviceProfile { - val generationToken = context.config.experimental.spoof.randomizeDeviceProfile.profileGenerationToken.getNullable() - return randomizedProfile ?: RandomizedDeviceProfileStore + if (randomizedProfile != null) return randomizedProfile!! + + val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile + val configProfileJson = spoofConfig.profileData.getNullable() + + if (!configProfileJson.isNullOrBlank()) { + runCatching { + val profile = RandomizedDeviceProfile.fromJson(configProfileJson) + randomizedProfile = profile + context.log.verbose("Using restored randomized device profile from config") + return profile + }.onFailure { + context.log.warn("Failed to parse restored device profile from config, generating fresh one: ${it.message}") + } + } + + val generationToken = spoofConfig.profileGenerationToken.getNullable() + return RandomizedDeviceProfileStore .getOrCreate(context.androidContext, context.log, generationToken) .also { profile -> randomizedProfile = profile @@ -155,18 +171,23 @@ class DeviceSpooferHook : Feature("Device Spoofer") { private fun persistRandomizedProfileSnapshot(profile: RandomizedDeviceProfile) { val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile + val profileJson = profile.toJson().toString() val snapshot = profile.toJson().toString(2) - if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot) return + + if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot && spoofConfig.profileData.getNullable() == profileJson) return + spoofConfig.currentProfileSnapshot.set(snapshot) + spoofConfig.profileData.set(profileJson) // Synchronize raw profile data for multi-process persistence + runCatching { val field = context.javaClass.getDeclaredField("_config\$delegate") field.isAccessible = true val lazyConfig = field.get(context) as Lazy<*> val modConfig = lazyConfig.value as? ModConfig ?: return@runCatching modConfig.writeConfig(dispatchConfigListener = false) - context.log.verbose("Persisted randomized device profile snapshot to config") + context.log.verbose("Persisted randomized device profile data to config") }.onFailure { - context.log.warn("Failed to persist randomized device profile snapshot: ${it.message}") + context.log.warn("Failed to persist randomized device profile: ${it.message}") } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt index 91e82fc8..509e1534 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt @@ -103,7 +103,7 @@ class SendOverride : Feature("Send Override") { } private fun handleQueuedOriginalItemRepeatSuccess(): Boolean { - if (queuedOriginalItemRepeatCount <= 0) { + if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) { clearQueuedOriginalItemRepeats() return false } @@ -152,13 +152,13 @@ class SendOverride : Feature("Send Override") { } private fun updateContinuousSendNotification() { - if (!engineActive.get() || isStopped.get()) return + if (!engineActive.get()) return val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) val remaining = queuedOriginalItemRepeatCount val processed = processedRepeatCount val total = totalRepeatCount - val isWorking = (remaining > 0 || (total > 0 && processed < total)) && !isStopped.get() + val isWorking = remaining > 0 && !isStopped.get() && engineActive.get() if (!isWorking) { notificationManager.cancel(STATUS_NOTIFICATION_ID) @@ -186,13 +186,18 @@ class SendOverride : Feature("Send Override") { } private fun showCompletionNotification(sent: Int, total: Int) { - val title = if (isStopped.get()) "Continuous Send Stopped" else "Continuous Send Finished" - val content = "Successfully sent $sent / $total snaps to $currentRecipientName" + val isError = sent < total && !isStopped.get() + val title = when { + isStopped.get() -> "Continuous Send Stopped" + isError -> "Continuous Send Failed" + else -> "Continuous Send Finished" + } + val content = "Sent $sent / $total snaps to $currentRecipientName" val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java) val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID) - .setSmallIcon(android.R.drawable.checkbox_on_background) // Emerald Green Checkmark - .setColor(0xFF2ECC71.toInt()) // Emerald Green tint + .setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background) + .setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt()) .setContentTitle(title) .setContentText(content) .setAutoCancel(true) @@ -928,6 +933,11 @@ class SendOverride : Feature("Send Override") { if (repeatCount <= 0) return false fun sendIteration(index: Int) { + if (isStopped.get()) { + clearQueuedOriginalItemRepeats() + updateContinuousSendNotification() + return + } val callback = if (index == repeatCount - 1) { originalCallback } else { From 347115f8d3ee3efa1ffd5fac69b9349a7854b59c Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Sat, 25 Apr 2026 04:36:36 +0530 Subject: [PATCH 15/20] Snapchat Plus bug fixes --- .../core/event/EventDispatcher.kt | 3 +- .../core/features/impl/global/SnapchatPlus.kt | 96 +++++++++---------- .../core/ui/ViewAppearanceHelper.kt | 2 + 3 files changed, 47 insertions(+), 54 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/event/EventDispatcher.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/event/EventDispatcher.kt index 9765cd33..1fd1f29f 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/event/EventDispatcher.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/event/EventDispatcher.kt @@ -47,7 +47,7 @@ class EventDispatcher( cacheHook( methodParam.thisObject()::class.java ) { - hook(bindMethod.get().toString(), HookStage.BEFORE) bindViewMethod@{ param -> + hook(bindMethod.get().toString(), HookStage.AFTER) bindViewMethod@{ param -> val instance = param.thisObject() val view = instance::class.java.methods.firstOrNull { it.name == getViewMethod.get().toString() @@ -161,7 +161,6 @@ class EventDispatcher( adapter = param } ) { - if (canceled) param.setResult(null) postHookEvent() } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt index aa5fbb98..f6cddd77 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/SnapchatPlus.kt @@ -17,67 +17,59 @@ class SnapchatPlus: Feature("SnapchatPlus") { override fun init() { val snapchatPlusTier = context.config.global.snapchatPlus.getNullable() + if (snapchatPlusTier == null || snapchatPlusTier == "not_subscribed") return - if (snapchatPlusTier != null) { - context.mappings.useMapper(PlusSubscriptionMapper::class) { - classReference.get()?.hookConstructor(HookStage.AFTER) { param -> - param.thisObject().dataBuilder { - //subscription tier - if (get(tierField.getAsString()!!)?.javaClass?.isEnum == true) { - set(tierField.getAsString()!!, when (snapchatPlusTier) { - "not_subscribed" -> "NO_ACCESS" - "basic" -> "SNAPCHAT_PLUS" - "ad_free" -> "SNAPCHAT_PLUS_AD_FREE" - else -> "SNAPCHAT_PLUS" - }) - } else { - set(tierField.getAsString()!!, when (snapchatPlusTier) { - "not_subscribed" -> 1 - "basic" -> 2 - "ad_free" -> 3 - else -> 2 - }) - } + // Pre-calculate custom purchase date to eliminate main thread lag + val customPurchaseDateRaw = context.config.global.snapchatPlusPurchaseDate.get().trim() + val customPurchaseDateMillis = if (customPurchaseDateRaw.isNotEmpty()) { + runCatching { + LocalDate.parse(customPurchaseDateRaw, DateTimeFormatter.ISO_LOCAL_DATE) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() + } else (System.currentTimeMillis() - 7776000000L) // 3 months fallback - //subscription status - set(statusField.getAsString()!!, 2) - - val fallbackOriginalSubscriptionTime = System.currentTimeMillis() - 7776000000L - val customPurchaseDate = context.config.global.snapchatPlusPurchaseDate.get().trim() - val customPurchaseDateMillis = if (customPurchaseDate.isNotEmpty()) { - runCatching { - LocalDate - .parse(customPurchaseDate, DateTimeFormatter.ISO_LOCAL_DATE) - .atStartOfDay(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli() - }.getOrNull() - } else { - null - } - - set( - originalSubscriptionTimeMillisField.getAsString()!!, - customPurchaseDateMillis ?: fallbackOriginalSubscriptionTime - ) - set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis) + context.mappings.useMapper(PlusSubscriptionMapper::class) { + classReference.get()?.hookConstructor(HookStage.AFTER) { param -> + param.thisObject().dataBuilder { + //subscription tier + if (get(tierField.getAsString()!!)?.javaClass?.isEnum == true) { + set(tierField.getAsString()!!, when (snapchatPlusTier) { + "not_subscribed" -> "NO_ACCESS" + "basic" -> "SNAPCHAT_PLUS" + "ad_free" -> "SNAPCHAT_PLUS_AD_FREE" + else -> "SNAPCHAT_PLUS" + }) + } else { + set(tierField.getAsString()!!, when (snapchatPlusTier) { + "not_subscribed" -> 1 + "basic" -> 2 + "ad_free" -> 3 + else -> 2 + }) } + + //subscription status + set(statusField.getAsString()!!, 2) + + set( + originalSubscriptionTimeMillisField.getAsString()!!, + customPurchaseDateMillis + ) + set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis) } } } + // Force enable all premium features in the catalog if (context.config.experimental.hiddenSnapchatPlusFeatures.get()) { - findClass("com.snap.plus.FeatureCatalog").methods.last { - !it.name.contains("init") && - it.parameterTypes.isNotEmpty() && - it.parameterTypes[0].name != "java.lang.Boolean" - }.hook(HookStage.BEFORE) { param -> - val instance = param.thisObject() - val firstArg = param.argNullable(0) ?: return@hook - - instance.findFieldNamesByType(firstArg::class.java).forEach { fieldName -> - instance.setObjectField(fieldName, firstArg) + runCatching { + val featureCatalogClass = findClass("com.snap.plus.FeatureCatalog") + featureCatalogClass.hook("isFeatureEnabled", HookStage.BEFORE) { param -> + param.setResult(true) } + context.log.verbose("Successfully unlocked premium Snapchat features") } } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/ViewAppearanceHelper.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/ViewAppearanceHelper.kt index 731cfff5..d606f6f6 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/ViewAppearanceHelper.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/ViewAppearanceHelper.kt @@ -137,6 +137,8 @@ fun View.onAttachChange(onAttach: (View.OnAttachStateChangeListener) -> Unit = { fun View.hideViewCompletely() { fun hide() { + if (visibility == View.GONE && layoutParams?.width == 0 && layoutParams?.height == 0) return + isEnabled = false visibility = View.GONE setWillNotDraw(true) From f3fb9667d8a33809a3aec78617dc73a0f76ee278 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Sat, 25 Apr 2026 05:20:16 +0530 Subject: [PATCH 16/20] Chat Preview text bug fix --- .../impl/ui/FriendFeedMessagePreview.kt | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt index 625c35fa..63b34e67 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -9,6 +9,8 @@ import android.text.TextPaint import android.view.View import android.view.ViewGroup import android.graphics.Typeface +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -78,9 +80,10 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { val ffSdlPrimaryTextStartMargin = 6 * density val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * density).toInt() - val separatorHeight = (density * 2).toInt() + val safetyGap = (12 * density).toInt() val textPaint = TextPaint().apply { textSize = secondaryTextSize + isAntiAlias = true } context.event.subscribe(BuildMessageEvent::class) { param -> @@ -105,14 +108,14 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { } fetchMessages(conversationId) { - var maxTextHeight = 0 - val previewContainerHeight = messageCache[conversationId]?.sumOf { msg -> - val rect = Rect() - textPaint.getTextBounds(msg, 0, msg.length, rect) - rect.height().also { - if (it > maxTextHeight) maxTextHeight = it - }.plus(separatorHeight) - } ?: run { + val fontMetrics = textPaint.fontMetrics + val lineHeight = (fontMetrics.descent - fontMetrics.ascent).toInt() + val spacing = (2 * density).toInt() + + val messages = messageCache[conversationId] + val previewContainerHeight = if (messages.isNullOrEmpty()) 0 else (messages.size * (lineHeight + spacing)) + + if (previewContainerHeight == 0) { ffItem.layoutParams = ffItem.layoutParams.apply { height = ViewGroup.LayoutParams.MATCH_PARENT } @@ -120,22 +123,23 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { } ffItem.layoutParams = ffItem.layoutParams.apply { - height = feedEntryHeight + previewContainerHeight + separatorHeight + height = feedEntryHeight + (safetyGap * 2) + previewContainerHeight } cachedLayouts[conversationId] = frameLayout frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() { override fun draw(canvas: Canvas, paint: Paint) { - val offsetY = canvas.height.toFloat() - previewContainerHeight + val startY = feedEntryHeight.toFloat() + safetyGap paint.textSize = secondaryTextSize - paint.color = context.userInterface.colorPrimary + paint.color = Color(context.userInterface.colorPrimary).copy(alpha = 0.7f).toArgb() paint.typeface = Typeface.DEFAULT + paint.isAntiAlias = true - messageCache[conversationId]?.forEachIndexed { index, messageString -> + messages?.forEachIndexed { index, messageString -> canvas.drawText(messageString, - feedEntryHeight + ffSdlPrimaryTextStartMargin, - offsetY + index * maxTextHeight, + ffSdlAvatarSize + ffSdlAvatarMargin + (ffSdlPrimaryTextStartMargin * 2), + startY + (index + 1) * lineHeight + (index * spacing), paint ) } From cbe9754fbac5d006d2ede806261b69992b28aac1 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Sat, 25 Apr 2026 06:24:51 +0530 Subject: [PATCH 17/20] Disaperaing chats via adblock fix --- .../features/impl/ConfigurationOverride.kt | 19 ++++---- .../core/features/impl/global/AdBlockFix.kt | 45 ++----------------- .../impl/ui/FriendFeedMessagePreview.kt | 8 ++-- 3 files changed, 18 insertions(+), 54 deletions(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt index a9d66f63..915f21d0 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt @@ -69,6 +69,16 @@ class ConfigurationOverride : Feature("Configuration Override") { overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }, { true }, isAppExperiment = true) + overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, + { true }) + + overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false }) + overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false }) + + arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach { + overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" }) + } + run { val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null } val level7Value = { _: ConfigKeyInfo -> 700 } @@ -172,15 +182,6 @@ class ConfigurationOverride : Feature("Configuration Override") { }, { false }) - overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, - { true }) - - overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false }) - overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false }) - - arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach { - overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" }) - } overrideProperty("GIFTING_CHAT_BIRTHDAY_UPSELL_ENABLED", { context.config.userInterface.hideUiComponents.get().contains("hide_snapchat_plus_gift_reminders") }, { false }) classReference.getAsClass()?.hook( diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/AdBlockFix.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/AdBlockFix.kt index c630f242..8cb2e4e4 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/AdBlockFix.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/AdBlockFix.kt @@ -2,7 +2,6 @@ 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 @@ -35,7 +34,6 @@ class AdBlockFix : Feature("AdBlockFix") { hookFeedEntryTracking() hookMessagingFeedCallbacks() - hookChatFeedRowSuppression() hookOperaAutoSkip() } @@ -45,7 +43,7 @@ class AdBlockFix : Feature("AdBlockFix") { val conversationId = feedEntry.getObjectFieldOrNull("mConversationId")?.let(::SnapUUID)?.toString() ?: return@hookConstructor - if (isCampaignFeedEntry(feedEntry) || isChatAdShareFeedEntry(feedEntry)) { + if (isCampaignFeedEntry(feedEntry)) { adConversationIds.add(conversationId) } } @@ -123,36 +121,20 @@ class AdBlockFix : Feature("AdBlockFix") { } } - 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 instance = param.thisObject() val viewState = runCatching { - param.thisObject().getObjectField(viewStateField.get()!!)?.toString() + instance::class.java.methods.firstOrNull { it.name.contains("ViewState") || it.name == "g" }?.invoke(instance)?.toString() }.getOrNull() ?: return@hook if (viewState != "FULLY_DISPLAYED") return@hook val layerList = runCatching { - param.thisObject().getObjectField(layerListField.get()!!) as? ArrayList<*> + instance::class.java.methods.firstOrNull { it.name.contains("LayerList") || it.name == "l" }?.invoke(instance) as? ArrayList<*> }.getOrNull() ?: return@hook val paramMap = runCatching { layerList.map { Layer(it).paramMap }.firstOrNull() @@ -209,25 +191,6 @@ class AdBlockFix : Feature("AdBlockFix") { ?.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") { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt index 63b34e67..8f542f54 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -80,7 +80,7 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { val ffSdlPrimaryTextStartMargin = 6 * density val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * density).toInt() - val safetyGap = (12 * density).toInt() + val safetyGap = (6 * density).toInt() val textPaint = TextPaint().apply { textSize = secondaryTextSize isAntiAlias = true @@ -123,14 +123,14 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { } ffItem.layoutParams = ffItem.layoutParams.apply { - height = feedEntryHeight + (safetyGap * 2) + previewContainerHeight + height = feedEntryHeight + (safetyGap * 1.5f).toInt() + previewContainerHeight } cachedLayouts[conversationId] = frameLayout frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() { override fun draw(canvas: Canvas, paint: Paint) { - val startY = feedEntryHeight.toFloat() + safetyGap + val startY = feedEntryHeight.toFloat() + (1 * density).toInt() paint.textSize = secondaryTextSize paint.color = Color(context.userInterface.colorPrimary).copy(alpha = 0.7f).toArgb() paint.typeface = Typeface.DEFAULT @@ -138,7 +138,7 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { messages?.forEachIndexed { index, messageString -> canvas.drawText(messageString, - ffSdlAvatarSize + ffSdlAvatarMargin + (ffSdlPrimaryTextStartMargin * 2), + ffSdlAvatarSize + ffSdlAvatarMargin + (ffSdlPrimaryTextStartMargin * 3), startY + (index + 1) * lineHeight + (index * spacing), paint ) From d4a5a60cc1fb92180e38265b98fe6942f43f63df Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:23:44 +0530 Subject: [PATCH 18/20] Manager app stutters and lag fixes --- .../purrfectsnap/bridge/BridgeService.kt | 21 +++++---- .../pages/features/FeaturesRootSection.kt | 1 - .../scripting/ManageScriptReposSection.kt | 5 ++- .../manager/pages/social/AddFriendDialog.kt | 38 +++++++++------- .../ui/manager/pages/social/LoggedStories.kt | 14 +++--- .../manager/pages/social/SocialRootSection.kt | 9 +++- .../pages/themes/aphelion/AphelionHomeView.kt | 6 ++- .../pages/themes/legacy/LegacyTheme.kt | 6 ++- .../ManageFriendTrackerReposSection.kt | 5 ++- .../impl/experiments/AutoOpenSnaps.kt | 44 ++++++++++++++++--- .../impl/ui/FriendFeedMessagePreview.kt | 13 +++--- 11 files changed, 110 insertions(+), 52 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt index 8cd7de09..00c36f19 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -5,6 +5,9 @@ import android.content.Intent import android.os.IBinder import android.os.ParcelFileDescriptor import android.os.RemoteException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import me.eternal.purrfectsnap.RemoteSideContext import me.eternal.purrfectsnap.SharedContextHolder @@ -240,16 +243,18 @@ class BridgeService : Service() { if (chunkIndex == totalChunks - 1) { val finalFriends = friendAccumulator.toList() val finalGroups = groupAccumulator.toList() - - pendingSocialSnapshotCallback?.let { callback -> - pendingSocialSnapshotCallback = null - callback(finalFriends, finalGroups) - } - remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups) - remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups) - + friendAccumulator.clear() groupAccumulator.clear() + + remoteSideContext.coroutineScope.launch(Dispatchers.IO) { + pendingSocialSnapshotCallback?.let { callback -> + pendingSocialSnapshotCallback = null + callback(finalFriends, finalGroups) + } + remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups) + remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups) + } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt index fea98d07..1132d061 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/FeaturesRootSection.kt @@ -181,7 +181,6 @@ class FeaturesRootSection : Routes.Route() { } internal fun getRandomizedProfileSnapshot(): String { - context.config.load() return context.config.root.experimental.spoof.randomizeDeviceProfile.currentProfileSnapshot.getNullable() ?.takeIf { it.isNotBlank() } ?: (context.translation["manager.dialogs.randomize_device_profile.empty"] diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ManageScriptReposSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ManageScriptReposSection.kt index 26107c5f..5ff97258 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ManageScriptReposSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/scripting/ManageScriptReposSection.kt @@ -279,8 +279,9 @@ class ManageScriptReposSection : Routes.Route() { } override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = { - val repositories by remember(refreshTrigger.value) { - mutableStateOf>(runBlocking { context.database.getRepositories("script") }) + var repositories by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(refreshTrigger.value) { + repositories = context.database.getRepositories("script") } val density = LocalDensity.current val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt index 48ec4794..4bf8b687 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt @@ -225,24 +225,30 @@ class AddFriendDialog( friends: List, groups: List ) { - cachedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds) - cachedGroups = groups.run { - if (pinnedIds != null) { - sortedBy { -pinnedIds.indexOf(it.conversationId) } - } else { - // Priority sort for whitelisted groups - val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet() - sortedWith { a, b -> - val aSelected = whitelistedIds.contains(a.conversationId) - val bSelected = whitelistedIds.contains(b.conversationId) - if (aSelected != bSelected) if (aSelected) -1 else 1 - else a.name.compareTo(b.name, ignoreCase = true) + coroutineScope.launch(Dispatchers.IO) { + val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds) + val sortedGroups = groups.run { + if (pinnedIds != null) { + sortedBy { -pinnedIds.indexOf(it.conversationId) } + } else { + // Priority sort for whitelisted groups + val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet() + sortedWith { a, b -> + val aSelected = whitelistedIds.contains(a.conversationId) + val bSelected = whitelistedIds.contains(b.conversationId) + if (aSelected != bSelected) if (aSelected) -1 else 1 + else a.name.compareTo(b.name, ignoreCase = true) + } + } + } + withContext(Dispatchers.Main) { + cachedFriends = sortedFriends + cachedGroups = sortedGroups + if (friends.isNotEmpty() || groups.isNotEmpty()) { + timeoutJob?.cancel() + hasFetchError = false } } - } - if (friends.isNotEmpty() || groups.isNotEmpty()) { - timeoutJob?.cancel() - hasFetchError = false } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/LoggedStories.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/LoggedStories.kt index c2340729..540d56c9 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/LoggedStories.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/LoggedStories.kt @@ -35,6 +35,7 @@ import me.eternal.purrfectsnap.storage.getFriendInfo import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.util.Dialog import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper +import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import java.io.File import java.text.DateFormat import java.util.Date @@ -44,12 +45,11 @@ import kotlin.math.absoluteValue class LoggedStories : Routes.Route() { override val title: @Composable () -> Unit = { val navBackStackEntry by routes.navController.currentBackStackEntryAsState() - val text = remember(navBackStackEntry) { - navBackStackEntry?.arguments?.getString("id")?.let { - context.database.getFriendInfo(it)?.displayName - } + val userId = navBackStackEntry?.arguments?.getString("id") + val displayName by rememberAsyncMutableState(defaultValue = null) { + userId?.let { context.database.getFriendInfo(it)?.displayName } } - text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } + displayName?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } } @OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class) @@ -57,7 +57,9 @@ class LoggedStories : Routes.Route() { val userId = navBackStackEntry.arguments?.getString("id") ?: return@content val stories = remember { mutableStateListOf() } - val friendInfo = remember { context.database.getFriendInfo(userId) } + val friendInfo by rememberAsyncMutableState(defaultValue = null) { + context.database.getFriendInfo(userId) + } var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) } var selectedStory by remember { mutableStateOf(null) } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt index 7f81ad87..03551345 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/SocialRootSection.kt @@ -65,8 +65,13 @@ class SocialRootSection : Routes.Route() { // Real-time synchronization from the bridge context.database.messagingDataFlow.collect { (friends, groups) -> - friendList = context.sortSocialFriends(friends) - groupList = groups + withContext(Dispatchers.IO) { + val sortedFriends = context.sortSocialFriends(friends) + withContext(Dispatchers.Main) { + friendList = sortedFriends + groupList = groups + } + } } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt index 590e1732..2e8a3537 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt @@ -444,8 +444,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { } hasInitialized -> storedTiles else -> { - context.database.setQuickTiles(allQuickTileNames) - prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply() + context.coroutineScope.launch(Dispatchers.IO) { + context.database.setQuickTiles(allQuickTileNames) + prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply() + } allQuickTileNames } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index bd0b8914..3acda50a 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -337,8 +337,10 @@ object LegacyTheme : ThemeContract { } hasInitializedQuickTiles -> storedTiles else -> { - context.database.setQuickTiles(allQuickTileNames) - prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply() + context.coroutineScope.launch(Dispatchers.IO) { + context.database.setQuickTiles(allQuickTileNames) + prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply() + } allQuickTileNames } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/ManageFriendTrackerReposSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/ManageFriendTrackerReposSection.kt index 38e1710b..c2390819 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/ManageFriendTrackerReposSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/tracker/ManageFriendTrackerReposSection.kt @@ -269,8 +269,9 @@ class ManageFriendTrackerReposSection: Routes.Route() { } override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = { - val repositories by remember(refreshTrigger.value) { - mutableStateOf>(runBlocking { context.database.getRepositories("friend_tracker") }) + var repositories by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(refreshTrigger.value) { + repositories = context.database.getRepositories("friend_tracker") } val density = LocalDensity.current val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index fc36dc09..49a2a7f3 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import me.eternal.purrfectsnap.bridge.AutoOpenInterface import me.eternal.purrfectsnap.common.config.PropertyValue +import me.eternal.purrfectsnap.common.config.ModConfig import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.MessageState import me.eternal.purrfectsnap.common.data.MessageUpdate @@ -93,6 +94,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private val lastSaveTime = AtomicLong(System.currentTimeMillis()) private var isThermalThrottled = false private var lastThermalThrottleAt = 0L + private var actionReceiver: BroadcastReceiver? = null private fun logInfo(msg: String) = this@AutoOpenSnaps.context.log.info("[AutoOpenEngine] $msg") private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg", e) else this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg") @@ -111,6 +113,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } override fun init() { + if (autoOpenConfig.globalState == false) return + restorePersistence() createNotificationChannels() @@ -477,7 +481,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } private fun setupReceivers() { - val actionReceiver = object : BroadcastReceiver() { + actionReceiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context?, intent: Intent?) { when (intent?.action) { ACTION_PAUSE_RESUME -> { isPaused.set(!isPaused.get()); updateStatusNotification(force = true) } @@ -501,8 +505,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A addAction(Intent.ACTION_SCREEN_OFF) addAction(Intent.ACTION_BATTERY_CHANGED) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver!!, filter, Context.RECEIVER_NOT_EXPORTED) + else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver!!, filter) } private fun recordSpeedTimestamp() { synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 250) snapTimestamps.removeFirst() } } @@ -510,9 +514,39 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun shutdownFeature() { engineActive.set(false) engineJob?.cancel() - releaseWakeLock() - cancelStatusNotification() saveQueueToDisk() + + // Permanently disable the feature in settings + autoOpenConfig.globalState = false + this@AutoOpenSnaps.context.coroutineScope.launch { + runCatching { + val field = context::class.java.getDeclaredField("_config").apply { isAccessible = true } + val modConfig = (field.get(context) as Lazy<*>).value as ModConfig + modConfig.writeConfig() + } + } + + // Surgical clean-up: release resources and listeners + actionReceiver?.let { + runCatching { this@AutoOpenSnaps.context.androidContext.unregisterReceiver(it) } + } + actionReceiver = null + wakeLockCooldownJob?.cancel() + + // Grace period for WakeLock release + this@AutoOpenSnaps.context.coroutineScope.launch { + delay(60000) + releaseWakeLock() + } + + // Show final "Stopped" notice + val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status") + .setOngoing(false) + .setSmallIcon(android.R.drawable.ic_menu_close_clear_cancel) + .setContentTitle("Auto-Open") + .setContentText("Auto-Open Engine Disabled. Re-enable in settings.") + + notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) } private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt index 8f542f54..1fcb9fbb 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FriendFeedMessagePreview.kt @@ -108,9 +108,10 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { } fetchMessages(conversationId) { - val fontMetrics = textPaint.fontMetrics + val universalTextSize = 12 * density + val fontMetrics = textPaint.apply { textSize = universalTextSize }.fontMetrics val lineHeight = (fontMetrics.descent - fontMetrics.ascent).toInt() - val spacing = (2 * density).toInt() + val spacing = (4 * density).toInt() val messages = messageCache[conversationId] val previewContainerHeight = if (messages.isNullOrEmpty()) 0 else (messages.size * (lineHeight + spacing)) @@ -123,16 +124,16 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") { } ffItem.layoutParams = ffItem.layoutParams.apply { - height = feedEntryHeight + (safetyGap * 1.5f).toInt() + previewContainerHeight + height = feedEntryHeight + (safetyGap).toInt() + previewContainerHeight } cachedLayouts[conversationId] = frameLayout frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() { override fun draw(canvas: Canvas, paint: Paint) { - val startY = feedEntryHeight.toFloat() + (1 * density).toInt() - paint.textSize = secondaryTextSize - paint.color = Color(context.userInterface.colorPrimary).copy(alpha = 0.7f).toArgb() + val startY = feedEntryHeight.toFloat() - (9 * density) + paint.textSize = universalTextSize + paint.color = Color(context.userInterface.colorPrimary).copy(alpha = 0.85f).toArgb() paint.typeface = Typeface.DEFAULT paint.isAntiAlias = true From 9934bd2f23c7e07e086407dd0601ef237b3c9603 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:06:14 +0530 Subject: [PATCH 19/20] Update checker refactor --- .../purrfectsnap/ui/manager/data/Updater.kt | 6 ++-- .../ui/manager/pages/home/HomeSettings.kt | 33 +++---------------- .../pages/themes/aphelion/AphelionHomeView.kt | 14 +++----- .../themes/aphelion/AphelionSettingsView.kt | 10 ------ .../pages/themes/legacy/LegacyTheme.kt | 25 +++----------- common/src/main/assets/lang/ar_AE.json | 2 +- common/src/main/assets/lang/en_US.json | 2 +- .../purrfectsnap/common/config/impl/Global.kt | 2 -- 8 files changed, 18 insertions(+), 76 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt index 0d498a3d..a93096e2 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt @@ -112,11 +112,11 @@ object Updater { private val cache = mutableMapOf() fun getLatestRelease(channel: Channel): LatestRelease? { - return cache.getOrPut(channel) { + return cache.getOrPut(Channel.STABLE) { if (BuildConfig.DEBUG) { - fetchLatestDebugCI() ?: fetchLatestRelease(channel) + fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE) } else { - fetchLatestRelease(channel) + fetchLatestRelease(Channel.STABLE) } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt index f174213e..a5053061 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt @@ -75,34 +75,9 @@ class HomeSettings : Routes.Route() { internal fun scheduleUpdateCheck() { val workManager = WorkManager.getInstance(context.androidContext) val updateSettings = context.config.root.global.updateSettings - var configDirty = false - val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run { - configDirty = true - updateSettings.autoUpdateCheck.set(true) - true - } - val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run { - configDirty = true - updateSettings.updateCheckFrequency.set("daily") - "daily" - } - val updateChannel = updateSettings.updateChannel.getNullable() ?: run { - configDirty = true - updateSettings.updateChannel.set("stable") - "stable" - } - if (configDirty) { - context.config.writeConfig() - } + val autoUpdateCheck = updateSettings.autoUpdateCheck.get() if (autoUpdateCheck) { - val repeatInterval = when (frequency) { - "daily" -> 1L - "weekly" -> 7L - "monthly" -> 30L - else -> 1L - } - val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() @@ -112,17 +87,17 @@ class HomeSettings : Routes.Route() { .putString("channel_description", translation["update_notification_channel_description"]) .putString("notification_title", translation["update_notification_title"]) .putString("notification_text", translation["update_notification_text"]) - .putString("update_channel", updateChannel) + .putString("update_channel", "stable") .build() - val workRequest = PeriodicWorkRequestBuilder(repeatInterval, TimeUnit.DAYS) + val workRequest = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) .setConstraints(constraints) .setInputData(inputData) .build() workManager.enqueueUniquePeriodicWork( "purrfectsnap_update_check", - ExistingPeriodicWorkPolicy.REPLACE, + ExistingPeriodicWorkPolicy.KEEP, workRequest ) } else { diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt index 2e8a3537..06f45f6f 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionHomeView.kt @@ -226,7 +226,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { downloadState: UpdateDownloader.DownloadState, downloadProgress: Float, onUpdateAction: () -> Unit, - channelLabel: String, isPurrAuraActive: Boolean, onAboutClick: () -> Unit, avenirNext: FontFamily, @@ -283,7 +282,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel)) + HeroBadge(translation.format("hero_version_label", "version" to versionName)) gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let { HeroBadge(translation.format("hero_build_label", "build" to it)) } @@ -453,10 +452,8 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { } } - val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable" - val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] ?: "" else translation["channel_label_stable"] ?: "" - val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) { - Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE) + val latestUpdate by rememberAsyncMutableState(defaultValue = null) { + Updater.getLatestRelease(Channel.STABLE) } val downloadState by UpdateDownloader.downloadState.collectAsState() val downloadProgress by UpdateDownloader.downloadProgress.collectAsState() @@ -504,7 +501,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { changelogLoading = true changelogError = null coroutineScope.launch(Dispatchers.IO) { - val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl + val url = changelogStableUrl runCatching { OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response -> val body = response.body?.string() ?: throw IllegalStateException("Empty body") @@ -542,7 +539,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { fullChangelogLoading = true fullChangelogError = null coroutineScope.launch(Dispatchers.IO) { - val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl + val url = changelogStableUrl runCatching { OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response -> val body = response.body?.string() ?: throw IllegalStateException("Empty body") @@ -694,7 +691,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) { downloadState = downloadState, downloadProgress = downloadProgress, onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } }, - channelLabel = channelLabel, isPurrAuraActive = isPurrAuraActive, onAboutClick = { routes.about.navigate() }, avenirNext = avenirNext, diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt index 84e8adff..5fc222b9 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionSettingsView.kt @@ -238,22 +238,12 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) { RowTitle(title = translation["updates_title"]) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) } - var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") } - var channelMenuExpanded by remember { mutableStateOf(false) } ShiftedRow { Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Text(text = translation["auto_update_check"], fontSize = 14.sp) Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors()) } } - AnimatedVisibility(visible = autoUpdateCheck) { - ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) { - AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true }) - ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) { - listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) } - } - } - } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index 3acda50a..26517337 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -190,7 +190,6 @@ object LegacyTheme : ThemeContract { downloadState: UpdateDownloader.DownloadState, downloadProgress: Float, onUpdateAction: () -> Unit, - channelLabel: String, isPurrAuraActive: Boolean, onWebsiteClick: () -> Unit, onTelegramClick: () -> Unit, @@ -223,7 +222,7 @@ object LegacyTheme : ThemeContract { horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) { - HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel)) + HeroBadge(translation.format("hero_version_label", "version" to versionName)) gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let { HeroBadge(translation.format("hero_build_label", "build" to it)) } @@ -345,13 +344,10 @@ object LegacyTheme : ThemeContract { } } } - val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable" - val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] ?: "" else translation["channel_label_stable"] ?: "" - val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) { - val channel = if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE - Updater.getLatestRelease(channel) + val latestUpdate by rememberAsyncMutableState(defaultValue = null) { + Updater.getLatestRelease(Channel.STABLE) } - val changelogUrl = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl + val changelogUrl = changelogStableUrl val downloadState by UpdateDownloader.downloadState.collectAsState() val downloadProgress by UpdateDownloader.downloadProgress.collectAsState() val coroutineScope = rememberCoroutineScope() @@ -502,7 +498,6 @@ object LegacyTheme : ThemeContract { downloadState = downloadState, downloadProgress = downloadProgress, onUpdateAction = onUpdateButtonClick, - channelLabel = channelLabel, isPurrAuraActive = isPurrAuraActive, onWebsiteClick = { context.androidContext.openLink("https://purrfectsnap.vercel.app/", context.translation["toast_open_link_failed"]) }, onTelegramClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"]) }, @@ -847,24 +842,12 @@ object LegacyTheme : ThemeContract { RowTitle(title = translation["updates_title"]) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) } - var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") } - var channelMenuExpanded by remember { mutableStateOf(false) } ShiftedRow { Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Text(text = translation["auto_update_check"], fontSize = 14.sp) Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors()) } } - AnimatedVisibility(visible = autoUpdateCheck) { - ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) { - AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true }) - ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) { - listOf("stable", "prerelease").forEach { channel -> - DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) - } - } - } - } } } diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 37e6fdb5..b42bb9a2 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -202,7 +202,7 @@ "update_content": "الإصدار {version} متاح!", "update_button": "تنزيل", "hero_tagline": "وحدة Xposed تهدف لتحسين تجربة Snapchat الخاصة بك", - "hero_version_label": "الإصدار: {version} - {channel}", + "hero_version_label": "الإصدار: {version}", "hero_build_label": "البناء: {build}", "update_ready_label": "جاهز للتثبيت", "purr_aura_active_label": "PurrAura نشط!", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 2b7149d3..8e4e5440 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -202,7 +202,7 @@ "update_content": "Version {version} is available!", "update_button": "Download", "hero_tagline": "An Xposed Module meant to enhance your Snapchat experience", - "hero_version_label": "Version: {version} - {channel}", + "hero_version_label": "Version: {version}", "hero_build_label": "Build: {build}", "update_ready_label": "Ready to install", "purr_aura_active_label": "PurrAura Active!", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt index 90a87b42..5b13f14b 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Global.kt @@ -78,8 +78,6 @@ class Global : ConfigContainer() { inner class UpdateSettings : ConfigContainer() { val autoUpdateCheck = boolean("auto_update_check", true) - val updateCheckFrequency = unique("update_check_frequency", "daily", "weekly", "monthly") - val updateChannel = unique("update_channel", "stable", "prerelease") } inner class UISettings : ConfigContainer() { From c0c395b100e319cd9f0ff73ec2b515618b3fabfd Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Sun, 26 Apr 2026 05:16:02 +0530 Subject: [PATCH 20/20] FFmpeg stability fixes --- .../purrfectsnap/bridge/BridgeService.kt | 38 ++++++++++--------- .../purrfectsnap/download/FFMpegProcessor.kt | 12 +++--- .../purrfectsnap/ui/manager/data/Updater.kt | 6 +-- .../ui/manager/pages/home/HomeSettings.kt | 2 +- 4 files changed, 30 insertions(+), 28 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt index 00c36f19..88e661b6 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -231,29 +231,31 @@ class BridgeService : Service() { chunkIndex: Int, totalChunks: Int ) { - if (chunkIndex == 0) { - friendAccumulator.clear() - groupAccumulator.clear() - } + synchronized(friendAccumulator) { + if (chunkIndex == 0) { + friendAccumulator.clear() + groupAccumulator.clear() + } - remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends") - friendAccumulator.addAll(friends.mapNotNull { toParcelable(it) }) - groupAccumulator.addAll(groups.mapNotNull { toParcelable(it) }) + remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends") + friendAccumulator.addAll(friends.mapNotNull { toParcelable(it) }) + groupAccumulator.addAll(groups.mapNotNull { toParcelable(it) }) - if (chunkIndex == totalChunks - 1) { - val finalFriends = friendAccumulator.toList() - val finalGroups = groupAccumulator.toList() + if (chunkIndex == totalChunks - 1) { + val finalFriends = friendAccumulator.toList() + val finalGroups = groupAccumulator.toList() - friendAccumulator.clear() - groupAccumulator.clear() + friendAccumulator.clear() + groupAccumulator.clear() - remoteSideContext.coroutineScope.launch(Dispatchers.IO) { - pendingSocialSnapshotCallback?.let { callback -> - pendingSocialSnapshotCallback = null - callback(finalFriends, finalGroups) + remoteSideContext.coroutineScope.launch(Dispatchers.IO) { + pendingSocialSnapshotCallback?.let { callback -> + pendingSocialSnapshotCallback = null + callback(finalFriends, finalGroups) + } + remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups) + remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups) } - remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups) - remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups) } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt index 4778968b..25d464f0 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -211,7 +211,7 @@ class FFMpegProcessor( filterSecondPart.append("[v$index][$index:a]") } else { containsNoSound = true - filterSecondPart.append("[v$index][${filesInfo.size}]") + filterSecondPart.append("[v$index][${filesInfo.size}:a]") } inputArguments += "-i" to file } @@ -228,9 +228,9 @@ class FFMpegProcessor( outputArguments += "-fps_mode" to "vfr" - outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\"" - outputArguments += "-map" to "\"[aout]\"" - outputArguments += "-map" to "\"[vout]\"" + outputArguments += "-filter_complex" to "$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]" + outputArguments += "-map" to "[aout]" + outputArguments += "-map" to "[vout]" } finally { filesInfo.forEach { it.second.close() } } @@ -264,8 +264,8 @@ class FFMpegProcessor( filterParts.append("[a$index]") } filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]") - outputArguments += "-filter_complex" to "\"$filterParts\"" - outputArguments += "-map" to "\"[aout]\"" + outputArguments += "-filter_complex" to filterParts.toString() + outputArguments += "-map" to "[aout]" } } outputArguments += args.output.absolutePath diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt index a93096e2..17d439a4 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/data/Updater.kt @@ -112,11 +112,11 @@ object Updater { private val cache = mutableMapOf() fun getLatestRelease(channel: Channel): LatestRelease? { - return cache.getOrPut(Channel.STABLE) { - if (BuildConfig.DEBUG) { + return cache.getOrPut(channel) { + if (BuildConfig.DEBUG && channel == Channel.STABLE) { fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE) } else { - fetchLatestRelease(Channel.STABLE) + fetchLatestRelease(channel) } } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt index a5053061..0df68729 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeSettings.kt @@ -97,7 +97,7 @@ class HomeSettings : Routes.Route() { workManager.enqueueUniquePeriodicWork( "purrfectsnap_update_check", - ExistingPeriodicWorkPolicy.KEEP, + ExistingPeriodicWorkPolicy.REPLACE, workRequest ) } else {