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 fc0f1d29..06e7c8cd 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 @@ -125,10 +125,6 @@ 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) } - } } } 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..3a092b38 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,5 +1,6 @@ package me.eternal.purrfectsnap.core.features.impl.messaging +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build @@ -64,15 +65,37 @@ 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?) { + // 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?) { queuedOriginalItemRepeatCount = repeatCount queuedOriginalItemRepeatOverrideType = overrideType queuedOriginalItemRepeatSnapDurationMs = snapDurationMs + lastCapturedDestinationsObj = destinations + lastCapturedMessageContentJson = contentJson + lastCapturedOriginalCallback = originalCallback MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs) } @@ -80,29 +103,68 @@ 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 } - private fun handleQueuedOriginalItemRepeatSuccess(): Boolean { - if (queuedOriginalItemRepeatCount <= 0) { - clearQueuedOriginalItemRepeats() - return false - } + 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) - val overrideType = queuedOriginalItemRepeatOverrideType ?: run { - clearQueuedOriginalItemRepeats() - return false - } - val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs + 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()) - queuedOriginalItemRepeatCount-- - MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs) - val result = MediaFilePicker.sendReusableOriginalItem() - if (!result) { - queuedOriginalItemRepeatCount++ - clearQueuedOriginalItemRepeats() - } - return result - } + 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) } private var selectedType by mutableStateOf("SNAP") @@ -196,6 +258,33 @@ 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 @@ -709,13 +798,13 @@ class SendOverride : Feature("Send Override") { } } - fun invokeSendManually(messageContent: MessageContent, callback: Any?) { + fun invokeSendManually(destinations: MessageDestinations, messageContent: MessageContent, callback: Any?) { val conversationManager = conversationManagerInstance ?: error("ConversationManager is null") internalMultipartSend.set(true) try { sendMessageWithContentMethod.invoke( conversationManager, - cloneDestinations(event.destinations), + cloneDestinations(destinations), messageContent.instanceNonNull(), callback ) @@ -725,6 +814,7 @@ class SendOverride : Feature("Send Override") { } fun sendMediaManual( + destinations: MessageDestinations, sourceMessageContent: MessageContent, overrideType: String, snapDurationMs: Int?, @@ -784,7 +874,7 @@ class SendOverride : Feature("Send Override") { .build() } - invokeSendManually(partContent, callback) + invokeSendManually(destinations, partContent, callback) } sendPart(0) @@ -794,11 +884,12 @@ class SendOverride : Feature("Send Override") { postSavePolicy = null val targetReader = ProtoReader(sourceMessageContent.content ?: return false) if (!applyOverride(sourceMessageContent, targetReader, overrideType, snapDurationMs)) return false - invokeSendManually(sourceMessageContent, completionCallback) + invokeSendManually(destinations, sourceMessageContent, completionCallback) return true } fun sendRepeatedMediaManual( + destinations: MessageDestinations, repeatCount: Int, overrideType: String, snapDurationMs: Int? @@ -820,7 +911,7 @@ class SendOverride : Feature("Send Override") { } val preparedContent = createMessageContentFromOriginal() - if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) { + if (!sendMediaManual(destinations, preparedContent, overrideType, snapDurationMs, callback)) { invokeCallbackError(originalCallback, "Failed to send") } } @@ -843,12 +934,76 @@ 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() + handleQueuedOriginalItemRepeatSuccess(conversationIds.first()) } else { false } @@ -1374,6 +1529,7 @@ class SendOverride : Feature("Send Override") { context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100) if (sendRepeatedMediaManual( + MessageDestinations(cloneDestinations(event.destinations)), repeatCount, finalSelectedType, selectedSnapDurationMs @@ -1429,7 +1585,19 @@ class SendOverride : Feature("Send Override") { invokeOriginalAndRestoreResult(event) } } else if (MediaFilePicker.hasReusableOriginalItem()) { - queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs) + totalRepeatCount = repeatCount + processedRepeatCount = 1 + currentRecipientName = recipientNameForTask + updateContinuousSendNotification() + + queueOriginalItemRepeats( + repeatCount - 1, + finalSelectedType, + selectedSnapDurationMs, + MessageDestinations(cloneDestinations(event.destinations)), + originalMessageJson, + originalCallback + ) attachQueuedRepeatCallbacks(event) if (sendMedia(finalSelectedType, selectedSnapDurationMs)) { invokeOriginalAndRestoreResult(event) @@ -1438,6 +1606,7 @@ class SendOverride : Feature("Send Override") { } } else { sendRepeatedMediaManual( + MessageDestinations(cloneDestinations(event.destinations)), repeatCount, finalSelectedType, selectedSnapDurationMs