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 {