From d724e5c6155d942928a1d2715587af1a72b508a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=8B=E1=B4=80=CA=9F=E1=B4=80=E1=B4=85=C9=AA=C9=B4?= <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:21:33 +0530 Subject: [PATCH] Fix: Auto-Open Ghost Notification & Social page friends selection bug (#145) --- .../purrfectsnap/download/FFMpegProcessor.kt | 17 +- .../eternal/purrfectsnap/storage/Messaging.kt | 20 +- .../purrfectsnap/ui/manager/MainActivity.kt | 6 +- .../pages/features/ManageRuleFeature.kt | 12 +- .../manager/pages/social/SocialRootSection.kt | 11 +- .../core/features/MessagingRuleFeature.kt | 2 +- .../impl/experiments/AutoOpenSnaps.kt | 8 +- .../impl/experiments/MediaFilePicker.kt | 37 ++- .../features/impl/messaging/SendOverride.kt | 211 ++++++++++-------- .../features/impl/tweaks/PerformanceMode.kt | 3 + 10 files changed, 192 insertions(+), 135 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 25d464f0..78fbc040 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -151,22 +151,27 @@ class FFMpegProcessor( } val outputArguments = ArgumentList().apply { - this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast") - this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264") this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy") - this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" } - this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K" this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K" } + fun applyVideoArguments() { + outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast") + outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264") + outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" } + outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K" + } + when (args.action) { Action.DOWNLOAD_DASH -> { + applyVideoArguments() outputArguments += "-ss" to "'${args.startTime}ms'" if (args.duration != null) { outputArguments += "-t" to "'${args.duration}ms'" } } Action.MERGE_OVERLAY -> { + applyVideoArguments() inputArguments += "-i" to args.overlay!!.absolutePath outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" } @@ -174,8 +179,9 @@ class FFMpegProcessor( if (ffmpegOptions.customAudioCodec.isEmpty()) { outputArguments -= "-c:a" } - outputArguments -= "-c:v" args.videoCodec?.let { + applyVideoArguments() + outputArguments -= "-c:v" outputArguments += "-c:v" to it } ?: run { outputArguments += "-vn" @@ -186,6 +192,7 @@ class FFMpegProcessor( } } Action.MERGE_MEDIA -> { + applyVideoArguments() inputArguments.clear() val filesInfo = args.inputs.mapNotNull { file -> runCatching { 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 f5272030..7d0a6e2d 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt @@ -97,15 +97,16 @@ fun AppDatabase.replaceMessagingData( database.beginTransaction() try { friends.forEach { friend -> + // Industrial Filter: Only update existing friends, never auto-insert new ones. database.execSQL( - "INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)", + "UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?", arrayOf( - friend.userId, friend.dmConversationId, friend.displayName, friend.mutableUsername, friend.bitmojiId, - friend.selfieId + friend.selfieId, + friend.userId ) ) @@ -124,12 +125,13 @@ fun AppDatabase.replaceMessagingData( } groups.forEach { group -> + // Industrial Filter: Only update existing groups. database.execSQL( - "INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)", + "UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?", arrayOf( - group.conversationId, group.name, - group.participantsCount + group.participantsCount, + group.conversationId ) ) } @@ -139,10 +141,8 @@ fun AppDatabase.replaceMessagingData( database.endTransaction() } - // Notify all observers with the updated data from the database - val allFriends = getFriends(descOrder = true) - val allGroups = getGroups() - messagingDataFlow.tryEmit(allFriends to allGroups) + // Notify all observers with the raw sync data (AddFriendDialog needs this) + messagingDataFlow.tryEmit(friends to groups) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt index 1ebd85ef..f677a91f 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt @@ -133,16 +133,16 @@ class MainActivity : ComponentActivity() { if (shouldShowAbiWarning) { AestheticDialog( onDismissRequest = {}, - title = managerContext.translation["wrong_apk_title"], + title = managerContext.translation["setup.activity.wrong_apk_title"], text = "", icon = Icons.Filled.Warning, - confirmButtonText = managerContext.translation["common.close"], + confirmButtonText = managerContext.translation["setup.activity.close_button"], onConfirm = { (context as? Activity)?.finishAffinity() }, showCloseButton = false, opaque = true, customContent = { Text( - text = managerContext.translation["wrong_apk_message"], + text = managerContext.translation["setup.activity.wrong_apk_message"], color = PurrfectPalette.textSecondary, lineHeight = 18.sp ) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt index 2acd0631..d3cc0ab4 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/features/ManageRuleFeature.kt @@ -140,7 +140,9 @@ class ManageRuleFeature : Routes.Route() { val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) { context.database.getRuleIds(currentRuleType.key) } - val ruleIdsSet by remember { derivedStateOf { currentRuleIds.toSet() } } + val currentRuleIdSet = remember(currentRuleIds.size) { + currentRuleIds.toSet() + } fun setRuleState(newState: RuleState?) { ruleState = newState @@ -166,7 +168,7 @@ class ManageRuleFeature : Routes.Route() { onFriendState = { friend, state -> context.database.setRule(friend.userId, currentRuleType.key, state) if (state) { - if (!currentRuleIds.contains(friend.userId)) currentRuleIds.add(friend.userId) + if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId) } else { currentRuleIds.remove(friend.userId) } @@ -174,16 +176,16 @@ class ManageRuleFeature : Routes.Route() { onGroupState = { group, state -> context.database.setRule(group.conversationId, currentRuleType.key, state) if (state) { - if (!currentRuleIds.contains(group.conversationId)) currentRuleIds.add(group.conversationId) + if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId) } else { currentRuleIds.remove(group.conversationId) } }, getFriendState = { friend -> - ruleIdsSet.contains(friend.userId) + currentRuleIdSet.contains(friend.userId) }, getGroupState = { group -> - ruleIdsSet.contains(group.conversationId) + currentRuleIdSet.contains(group.conversationId) } ) ) 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 556b46a6..c7fb2d97 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 @@ -67,12 +67,14 @@ class SocialRootSection : Routes.Route() { } // Real-time synchronization from the bridge - context.database.messagingDataFlow.collect { (friends, groups) -> + context.database.messagingDataFlow.collect { withContext(Dispatchers.IO) { - val sortedFriends = context.sortSocialFriends(friends) + val dbFriends = context.database.getFriends(descOrder = true) + val dbGroups = context.database.getGroups() + val sortedFriends = context.sortSocialFriends(dbFriends) withContext(Dispatchers.Main) { friendList = sortedFriends - groupList = groups + groupList = dbGroups } } } @@ -172,8 +174,7 @@ class SocialRootSection : Routes.Route() { }, getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null }, getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null } - ), - pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(), + ) ) }, modifier = Modifier diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/MessagingRuleFeature.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/MessagingRuleFeature.kt index d26192af..cebc3969 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/MessagingRuleFeature.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/MessagingRuleFeature.kt @@ -32,7 +32,7 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp } && getRuleState() != null } - fun canUseRule(conversationId: String): Boolean { + open fun canUseRule(conversationId: String): Boolean { if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) { return false } 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 ca48fb74..6aebaed9 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 @@ -113,7 +113,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } override fun init() { - if (autoOpenConfig.globalState == false) return + if (autoOpenConfig.globalState != true) return restorePersistence() createNotificationChannels() @@ -486,9 +486,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A builder.setStyle(bigTextStyle) } - notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) + runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) } } - private fun createPendingIntent(action: String): PendingIntent { val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName) return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) @@ -560,9 +559,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A .setContentTitle("Auto-Open") .setContentText("Auto-Open Engine Disabled. Re-enable in settings.") - notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) + runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) } } - private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID) fun getInterface(): AutoOpenInterface { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt index 9b0e715b..4e60c178 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/MediaFilePicker.kt @@ -70,14 +70,14 @@ class MediaFilePicker : Feature("Media File Picker") { private const val SNAP_CHUNK_DURATION_MS = 10_000L private val queuedSplitItems = ArrayDeque() private val queuedSplitItemIds = ArrayDeque() - private val queuedSplitCleanupUris = mutableMapOf() + private val queuedSplitCleanupItems = mutableMapOf() private var originalUnsplitItem: Any? = null private var reusableOriginalItem: Any? = null private var queuedOverrideType: String? = null private var queuedOverrideSnapDurationMs: Int? = null private var bypassSplitOnce = false private var sendSingleItemHandler: ((Any) -> Boolean)? = null - private var cleanupItemHandler: ((String) -> Unit)? = null + private var cleanupItemHandler: ((String, String?) -> Unit)? = null fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty() fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty() fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null @@ -91,13 +91,13 @@ class MediaFilePicker : Feature("Media File Picker") { fun clearQueuedSplitItems(deleteTempItems: Boolean = true) { if (deleteTempItems) { val cleanup = cleanupItemHandler - queuedSplitCleanupUris.values.toList().forEach { uri -> - cleanup?.invoke(uri) + queuedSplitCleanupItems.values.toList().forEach { item -> + cleanup?.invoke(item.uri, item.filePath) } } queuedSplitItems.clear() queuedSplitItemIds.clear() - queuedSplitCleanupUris.clear() + queuedSplitCleanupItems.clear() originalUnsplitItem = null queuedOverrideType = null queuedOverrideSnapDurationMs = null @@ -114,7 +114,7 @@ class MediaFilePicker : Feature("Media File Picker") { items.drop(1).forEach { queuedSplitItems.addLast(it) } preparedItems.forEach { queuedSplitItemIds.addLast(it.itemId) - queuedSplitCleanupUris[it.itemId] = it.uri + queuedSplitCleanupItems[it.itemId] = it } } fun sendOriginalUnsplitItem(): Boolean { @@ -130,8 +130,8 @@ class MediaFilePicker : Feature("Media File Picker") { } fun handleCurrentQueuedItemSuccess(): Boolean { queuedSplitItemIds.removeFirstOrNull()?.let { itemId -> - queuedSplitCleanupUris.remove(itemId)?.let { uri -> - cleanupItemHandler?.invoke(uri) + queuedSplitCleanupItems.remove(itemId)?.let { item -> + cleanupItemHandler?.invoke(item.uri, item.filePath) } } if (queuedSplitItems.isEmpty()) { @@ -159,7 +159,8 @@ class MediaFilePicker : Feature("Media File Picker") { private data class PreparedMediaItem( val itemId: String, val durationMs: Long, - val uri: String + val uri: String, + val filePath: String? = null ) private fun splitVideoIntoChunks( @@ -309,7 +310,12 @@ class MediaFilePicker : Feature("Media File Picker") { runCatching { resolver.delete(uri, null, null) } } - return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString()) + return PreparedMediaItem( + itemId = itemId, + durationMs = durationMs, + uri = uri.toString(), + filePath = file.absolutePath + ) } private fun buildDrawerItems(itemClass: Any, mediaItems: List): List { @@ -431,11 +437,18 @@ class MediaFilePicker : Feature("Media File Picker") { false } } - cleanupItemHandler = { uriString -> + cleanupItemHandler = { uriString, filePath -> runCatching { + // Industrial Cleanup: Direct file deletion is the gold standard for Android 14 + filePath?.let { path -> + val file = File(path) + if (file.exists()) file.delete() + } context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null) }.onFailure { - context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}") + if (it.message?.contains("no access") == false) { + context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}") + } } } if (sendItemsHookedHandler === handlerInstance) return@hook 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 509e1534..6d89ab00 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 @@ -34,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType import kotlinx.coroutines.* import me.eternal.purrfectsnap.bridge.task.TaskListener import me.eternal.purrfectsnap.common.data.ContentType +import me.eternal.purrfectsnap.common.config.PropertyValue import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter +import me.eternal.purrfectsnap.core.ModContext import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent @@ -88,6 +90,7 @@ class SendOverride : Feature("Send Override") { private var currentRecipientName: String = "Unknown" private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false) private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false) + private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true) private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) { queuedOriginalItemRepeatCount = repeatCount @@ -102,24 +105,93 @@ class SendOverride : Feature("Send Override") { queuedOriginalItemRepeatSnapDurationMs = null } - private fun handleQueuedOriginalItemRepeatSuccess(): Boolean { + private fun updateContinuousSendNotification(context: ModContext) { + 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 && !isStopped.get() && engineActive.get() + + if (!isWorking) { + notificationManager.cancel(STATUS_NOTIFICATION_ID) + showCompletionNotification(context, 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) + .setColor(0xFF3498DB.toInt()) + .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(context, ACTION_PAUSE_RESUME)).build()) + builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(context, ACTION_STOP)).build()) + + notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) + } + + private fun showCompletionNotification(context: ModContext, sent: Int, total: Int) { + 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(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) + + notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build()) + } + + private fun createPendingIntent(context: ModContext, 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 handleQueuedOriginalItemRepeatSuccess(context: ModContext): Boolean { if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) { clearQueuedOriginalItemRepeats() + updateContinuousSendNotification(context) return false } val overrideType = queuedOriginalItemRepeatOverrideType ?: run { clearQueuedOriginalItemRepeats() + updateContinuousSendNotification(context) return false } val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs + processedRepeatCount++ queuedOriginalItemRepeatCount-- + updateContinuousSendNotification(context) + MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs) val result = MediaFilePicker.sendReusableOriginalItem() if (!result) { - queuedOriginalItemRepeatCount++ clearQueuedOriginalItemRepeats() + updateContinuousSendNotification(context) } return result } @@ -136,7 +208,6 @@ 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) { @@ -151,70 +222,6 @@ class SendOverride : Feature("Send Override") { } } - private fun updateContinuousSendNotification() { - 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 && !isStopped.get() && engineActive.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 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(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) - - 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 @@ -302,14 +309,14 @@ class SendOverride : Feature("Send Override") { when (intent?.action) { ACTION_PAUSE_RESUME -> { isPaused.set(!isPaused.get()) - updateContinuousSendNotification() + updateContinuousSendNotification(context) } ACTION_STOP -> { isStopped.set(true) if (isPaused.get()) { isPaused.set(false) } - updateContinuousSendNotification() + updateContinuousSendNotification(context) } } } @@ -858,10 +865,10 @@ class SendOverride : Feature("Send Override") { completionCallback: Any? ): Boolean { val sourceReader = ProtoReader(sourceMessageContent.content ?: return false) - val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0 + val mediaCount = (sourceReader.followPath(3) as? ProtoReader)?.getCount(3) ?: 0 if (overrideType != "ORIGINAL" && mediaCount > 1) { val mediaBuffers = mutableListOf() - sourceReader.followPath(3)?.eachBuffer { id, buffer -> + (sourceReader.followPath(3) as? ProtoReader)?.eachBuffer { id, buffer -> if (id == 3) mediaBuffers.add(buffer) } if (mediaBuffers.isEmpty()) return false @@ -933,27 +940,54 @@ 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 { - CallbackBuilder(sendMessageCallbackClass) + context.coroutineScope.launch { + while (isPaused.get() && !isStopped.get()) { + delay(500) + } + if (isStopped.get()) { + clearQueuedOriginalItemRepeats() + updateContinuousSendNotification(context) + return@launch + } + + val callback = CallbackBuilder(sendMessageCallbackClass) .override("onSuccess") { - sendIteration(index + 1) + processedRepeatCount++ + queuedOriginalItemRepeatCount-- + updateContinuousSendNotification(context) + + if (index < repeatCount - 1) { + sendIteration(index + 1) + } else { + // Batch Finished: Trigger original Snapchat callback + originalCallback?.let { cb -> + runCatching { + val method = cb.javaClass.methods.firstOrNull { it.name == "onSuccess" } + if (method != null) { + if (method.parameterCount == 0) { + method.invoke(cb) + } else { + // Pass null for all required parameters to safely trigger the completion UI + method.invoke(cb, *arrayOfNulls(method.parameterCount)) + } + } + }.onFailure { context.log.error("Failed to trigger completion handshake", it) } + } + } } .override("onError", shouldUnhook = false) { invokeCallbackError(originalCallback, it.argNullable(0)) + clearQueuedOriginalItemRepeats() + updateContinuousSendNotification(context) } .build() - } - val preparedContent = createMessageContentFromOriginal() - if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) { - invokeCallbackError(originalCallback, "Failed to send") + if (index > 0) delay(1000) // Human-like delay + + val preparedContent = createMessageContentFromOriginal() + if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) { + invokeCallbackError(originalCallback, "Failed to send") + } } } @@ -980,7 +1014,7 @@ class SendOverride : Feature("Send Override") { context.runOnUiThread { val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess() val handledRepeat = if (!handledSplit) { - handleQueuedOriginalItemRepeatSuccess() + handleQueuedOriginalItemRepeatSuccess(context) } else { false } @@ -1008,7 +1042,6 @@ class SendOverride : Feature("Send Override") { context.runOnUiThread { val recipientNameForTask = recipientName - val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0 createComposeAlertDialog(context.mainActivity!!) { alertDialog -> PurrfectOverlayTheme { @@ -1564,7 +1597,6 @@ class SendOverride : Feature("Send Override") { totalRepeatCount = repeatCount processedRepeatCount = 1 currentRecipientName = recipientNameForTask - updateContinuousSendNotification() queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs) attachQueuedRepeatCallbacks(event) @@ -1572,12 +1604,13 @@ class SendOverride : Feature("Send Override") { invokeOriginalAndRestoreResult(event) } else { clearQueuedOriginalItemRepeats() + updateContinuousSendNotification(context) } } else { totalRepeatCount = repeatCount processedRepeatCount = 0 + queuedOriginalItemRepeatCount = repeatCount currentRecipientName = recipientNameForTask - updateContinuousSendNotification() sendRepeatedMediaManual( repeatCount, 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 daedd055..8d484da2 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 @@ -347,6 +347,9 @@ class PerformanceMode : Feature("Performance Mode") { TextureView::class.java.hookConstructor(HookStage.AFTER) { param -> val textureView = param.thisObject() runCatching { + // Universal Guard: Only accelerate views owned by Snapchat. + // This prevents crashes in native hardware providers across all devices. + if (textureView.context.packageName != context.androidContext.packageName) return@runCatching textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null) } }