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 f19f10bb..9315f554 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt @@ -170,124 +170,22 @@ class DownloadProcessor ( } } - private fun saveToConfiguredFolder( - configuredFolder: String, - fileName: String, - fileType: FileType, - inputFile: File, - metadata: DownloadMetadata, - pendingTask: PendingTask, - ): GallerySaveResult { - val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder)) - ?: throw Exception("Failed to open output folder") - - val outputFileFolder = metadata.outputPath.let { - if (it.contains("/")) { - it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> - folder.findFile(name) - ?: folder.createDirectory(name) - ?: throw Exception("Failed to create output directory $name") - } - } else { - outputFolder - } - } - - outputFileFolder.findFile(fileName)?.let { existingFile -> - pendingTask.updateProgress("Comparing existing media") - if (existingFile.length() != inputFile.length()) { - existingFile.delete() - } else { - val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri) - ?: throw Exception("Failed to open existing media for comparison") - - existingInputStream.use { currentExistingInputStream -> - val buffer1 = ByteArray(1024 * 1024) - val buffer2 = ByteArray(1024 * 1024) - var read1: Int - var read2: Int - - inputFile.inputStream().use { inputStream -> - while (true) { - read1 = inputStream.read(buffer1) - read2 = currentExistingInputStream.read(buffer2) - if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) { - existingFile.delete() - return@let - } - if (read1 == -1) break - } + private fun streamsMatch(stream1: InputStream, stream2: InputStream): Boolean { + stream1.use { s1 -> + stream2.use { s2 -> + val buffer1 = ByteArray(1024 * 1024) + val buffer2 = ByteArray(1024 * 1024) + while (true) { + val read1 = s1.read(buffer1) + val read2 = s2.read(buffer2) + if (read1 != read2) return false + if (read1 == -1) return true + for (i in 0 until read1) { + if (buffer1[i] != buffer2[i]) return false } } - - return GallerySaveResult(existingFile.uri, alreadyDownloaded = true) } } - - val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName) - ?: throw Exception("Failed to create output file $fileName") - - pendingTask.updateProgress("Saving media to gallery") - val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri) - ?: throw Exception("Failed to open output stream for $fileName") - - outputStream.use { currentOutputStream -> - inputFile.inputStream().use { inputStream -> - inputStream.copyTo(currentOutputStream) - } - } - - return GallerySaveResult(outputFile.uri) - } - - private fun buffersMatch(left: ByteArray, right: ByteArray, length: Int): Boolean { - for (index in 0 until length) { - if (left[index] != right[index]) return false - } - return true - } - - private fun buildOutputFileName(outputPath: String, fileType: FileType): String { - val rawName = outputPath.substringAfterLast("/").ifBlank { "media" } - val sanitizedBase = sanitizeFileName(rawName.substringBeforeLast(".", rawName)) - val currentExtension = rawName.substringAfterLast(".", "").lowercase() - val targetExtension = fileType.fileExtension?.lowercase() ?: "dat" - val extension = if (currentExtension == targetExtension) { - currentExtension - } else { - targetExtension - } - return "$sanitizedBase.$extension" - } - - private fun sanitizeFileName(name: String): String { - return name - .replace(Regex("[\\\\/:*?\"<>|]"), "_") - .replace(Regex("\\p{Cntrl}"), "") - .replace(Regex("\\s+"), " ") - .trim() - .trim('.') - .ifBlank { "media" } - } - - private fun sanitizeRelativePath(path: String): String { - return path.split("/") - .mapNotNull { segment -> - segment.trim() - .takeIf { it.isNotBlank() } - ?.let(::sanitizeFileName) - } - .joinToString("/") - } - - private fun appendNameSuffix(fileName: String, index: Int): String { - val extension = fileName.substringAfterLast('.', "") - val baseName = fileName.substringBeforeLast(".", fileName) - return if (extension.isBlank()) { - "$baseName ($index)" - } else { - "$baseName ($index).$extension" - } } private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? { @@ -312,29 +210,103 @@ class DownloadProcessor ( return streamsMatch(existingInputStream, inputFile.inputStream()) } - private fun filesMatch(existingFile: File, inputFile: File): Boolean { - return streamsMatch(existingFile.inputStream(), inputFile.inputStream()) + private fun buildOutputFileName(outputPath: String, fileType: FileType): String { + val rawName = outputPath.trimEnd('/').substringAfterLast("/").ifBlank { "media" } + val targetExtension = fileType.fileExtension?.lowercase() ?: "dat" + + val baseName = if (rawName.lowercase().endsWith(".$targetExtension")) { + rawName.substringBeforeLast(".") + } else { + rawName + } + + return "${sanitizeFileName(baseName)}.$targetExtension" } - private fun streamsMatch(existingInputStream: InputStream, inputInputStream: InputStream): Boolean { - existingInputStream.use { currentExistingInputStream -> - val buffer1 = ByteArray(1024 * 1024) - val buffer2 = ByteArray(1024 * 1024) - var read1: Int - var read2: Int + private fun sanitizeFileName(name: String): String { + return name + .replace(Regex("[\\\\/:*?\"<>|]"), "_") + .replace(Regex("\\p{Cntrl}"), "") + .replace(Regex("\\s+"), " ") + .trim() + .trim('.') + .ifBlank { "media" } + } - inputInputStream.use { inputStream -> - while (true) { - read1 = inputStream.read(buffer1) - read2 = currentExistingInputStream.read(buffer2) - if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) { - return false - } - if (read1 == -1) break + private fun sanitizeRelativePath(path: String): String { + return path.trimEnd('/').split("/") + .mapNotNull { segment -> + segment.trim() + .takeIf { it.isNotBlank() } + ?.let(::sanitizeFileName) + } + .joinToString("/") + } + + private fun appendNameSuffix(fileName: String, index: Int): String { + val extension = fileName.substringAfterLast('.', "") + val baseName = fileName.substringBeforeLast(".", fileName) + return if (extension.isBlank()) { + "$baseName ($index)" + } else { + "$baseName ($index).$extension" + } + } + + private fun saveToConfiguredFolder( + configuredFolder: String, + fileName: String, + fileType: FileType, + inputFile: File, + metadata: DownloadMetadata, + pendingTask: PendingTask, + ): GallerySaveResult { + val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder)) + ?: throw Exception("Failed to open output folder") + + val outputFileFolder = metadata.outputPath.let { + if (it.contains("/")) { + it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name -> + folder.findFile(name) + ?: folder.createDirectory(name) + ?: throw Exception("Failed to create output directory $name") } + } else { + outputFolder } } - return true + + var finalFileName = fileName + var collisionCount = 0 + + while (true) { + val existingFile = outputFileFolder.findFile(finalFileName) ?: break + + if (existingFile.length() == inputFile.length()) { + val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri) + if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) { + return GallerySaveResult(existingFile.uri, alreadyDownloaded = true) + } + } + + collisionCount++ + finalFileName = appendNameSuffix(fileName, collisionCount) + } + + val outputFile = outputFileFolder.createFile(fileType.mimeType, finalFileName) + ?: throw Exception("Failed to create output file $finalFileName") + + pendingTask.updateProgress("Saving media to gallery") + val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri) + ?: throw Exception("Failed to open output stream for $finalFileName") + + outputStream.use { currentOutputStream -> + inputFile.inputStream().use { inputStream -> + inputStream.copyTo(currentOutputStream) + } + } + + return GallerySaveResult(outputFile.uri) } private fun saveToSystemDefault( @@ -346,7 +318,6 @@ class DownloadProcessor ( val subPath = sanitizeRelativePath( metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "") .replace("\\", "/") - .trim('/') ) val baseRelative = when { fileType.isImage -> Environment.DIRECTORY_PICTURES @@ -356,86 +327,65 @@ class DownloadProcessor ( val relativePath = listOfNotNull(baseRelative, "PurrfectSnap", subPath.takeIf { it.isNotBlank() }) .joinToString("/") + "/" - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val collection = when { fileType.isImage -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) } val resolver = remoteSideContext.androidContext.contentResolver - val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName -> - val extension = fileName.substringAfterLast('.', "") - if (extension.isBlank()) baseName else "$baseName.$extension" - } - findExistingMediaUri(collection, sanitizedFileName, relativePath)?.let { existingUri -> - if (contentMatches(existingUri, inputFile)) { - remoteSideContext.log.verbose("Media already exists in gallery: $sanitizedFileName") - return GallerySaveResult(existingUri, alreadyDownloaded = true) - } - } - + for (attempt in 0..100) { - val candidateName = if (attempt == 0) sanitizedFileName else appendNameSuffix(sanitizedFileName, attempt) - val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName) - put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) - put(MediaStore.MediaColumns.IS_PENDING, 1) - } + val candidateName = if (attempt == 0) fileName else appendNameSuffix(fileName, attempt) + + findExistingMediaUri(collection, candidateName, relativePath)?.let { existingUri -> + if (contentMatches(existingUri, inputFile)) { + return GallerySaveResult(existingUri, alreadyDownloaded = true) + } + return@let + } ?: run { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName) + put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } - val uri = runCatching { - resolver.insert(collection, values) - }.onFailure { - remoteSideContext.log.verbose("MediaStore insert rejected $candidateName in $relativePath: ${it.message}") - }.getOrNull() ?: continue + val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: return@run - runCatching { - resolver.openOutputStream(uri)?.use { out -> - inputFile.inputStream().use { it.copyTo(out) } - } ?: throw IllegalStateException("Failed to open output stream for $candidateName") + runCatching { + resolver.openOutputStream(uri)?.use { out -> + inputFile.inputStream().use { it.copyTo(out) } + } ?: throw IllegalStateException("Failed to open output stream") - ContentValues().apply { - put(MediaStore.MediaColumns.IS_PENDING, 0) - }.also { resolver.update(uri, it, null, null) } - - return GallerySaveResult(uri) - }.onFailure { - runCatching { resolver.delete(uri, null, null) } - remoteSideContext.log.error("Failed writing media to gallery for $candidateName", it) + ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }.also { resolver.update(uri, it, null, null) } + return GallerySaveResult(uri) + }.onFailure { + runCatching { resolver.delete(uri, null, null) } + } } } - - throw IllegalStateException("Failed to allocate a unique gallery file for $sanitizedFileName in $relativePath") + throw IllegalStateException("Failed to allocate unique filename") } else { @Suppress("DEPRECATION") val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative) val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else "")) destDir.mkdirs() - val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName -> - val extension = fileName.substringAfterLast('.', "") - if (extension.isBlank()) baseName else "$baseName.$extension" - } - var destFile = File(destDir, sanitizedFileName) - if (destFile.exists()) { - if (destFile.length() == inputFile.length() && filesMatch(destFile, inputFile)) { + + var destFile = File(destDir, fileName) + var suffix = 1 + while (destFile.exists()) { + if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) { return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true) } - var suffix = 1 - while (destFile.exists()) { - destFile = File(destDir, appendNameSuffix(sanitizedFileName, suffix++)) - } - } - FileOutputStream(destFile).use { out -> - inputFile.inputStream().use { it.copyTo(out) } + destFile = File(destDir, appendNameSuffix(fileName, suffix++)) } + + FileOutputStream(destFile).use { out -> inputFile.inputStream().use { it.copyTo(out) } } runCatching { - remoteSideContext.androidContext.sendBroadcast( - Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { - data = Uri.fromFile(destFile) - } - ) + remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { data = Uri.fromFile(destFile) }) } - GallerySaveResult(Uri.fromFile(destFile)) + return GallerySaveResult(Uri.fromFile(destFile)) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/PendingTask.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/PendingTask.kt index 3b43d6ac..920cf03c 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/PendingTask.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/PendingTask.kt @@ -46,7 +46,8 @@ data class Task( val type: TaskType, val title: String, val author: String?, - val hash: String + val hash: String, + val isAutoOpen: Boolean = false ) { var changeListener: () -> Unit = {} diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/RemoteTaskInterface.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/RemoteTaskInterface.kt index 6df9ebfb..a3f6e92b 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/RemoteTaskInterface.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/RemoteTaskInterface.kt @@ -10,11 +10,13 @@ class RemoteTaskInterface( private val activeTasks = context.taskManager.getActiveTasks() override fun createTask(type: String, title: String, author: String, hash: String): String { + val taskType = TaskType.fromKey(type) val task = Task( - type = TaskType.fromKey(type), + type = taskType, title = title, author = author.takeIf { it.isNotBlank() }, - hash = hash + hash = hash, + isAutoOpen = taskType == TaskType.CHAT_ACTION ) context.taskManager.createPendingTask(task) return hash diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/TaskManager.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/TaskManager.kt index f42058b0..4e5ebef3 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/TaskManager.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/TaskManager.kt @@ -38,11 +38,13 @@ class TaskManager( private val activeTasks = mutableMapOf() private fun readTaskFromCursor(cursor: android.database.Cursor): Task { + val taskType = TaskType.fromKey(cursor.getStringOrNull("type")!!) val task = Task( - type = TaskType.fromKey(cursor.getStringOrNull("type")!!), + type = taskType, title = cursor.getStringOrNull("title")!!, author = cursor.getStringOrNull("author"), - hash = cursor.getStringOrNull("hash")!! + hash = cursor.getStringOrNull("hash")!!, + isAutoOpen = taskType == TaskType.CHAT_ACTION ) task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!) task.extra = cursor.getStringOrNull("extra") diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt index 8c030a2d..c37ef466 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/TasksRootSection.kt @@ -71,6 +71,11 @@ import me.eternal.purrfectsnap.ui.util.* import java.io.File class TasksRootSection : Routes.Route() { + enum class TaskTab { + ACTIVE, SCHEDULED + } + + internal var selectedTab by mutableStateOf(TaskTab.ACTIVE) internal var activeTasks by mutableStateOf(listOf()) internal var recentTasks = mutableStateListOf() internal val taskSelection = mutableStateListOf>() @@ -201,15 +206,94 @@ class TasksRootSection : Routes.Route() { ), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + item(key = "auto_open_card") { + var queueItems by remember { mutableStateOf(listOf()) } + var processedCount by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + while (true) { + runCatching { + val autoOpen = context.bridgeService?.messagingBridge?.getAutoOpenInterface() + processedCount = autoOpen?.processedCount ?: 0 + val items = autoOpen?.queueItems ?: emptyList() + queueItems = items.mapNotNull { + runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull() + } + } + kotlinx.coroutines.delay(2000) + } + } + + if (queueItems.isNotEmpty() || processedCount > 0) { + Surface( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + shape = MaterialTheme.shapes.large, + color = Color.White.copy(alpha = 0.05f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + IconButton(onClick = { + runCatching { context.bridgeService?.messagingBridge?.getAutoOpenInterface()?.reset() } + }) { + Icon(Icons.Default.Refresh, null, modifier = Modifier.size(20.dp)) + } + } + Text( + "${translation["auto_open_snaps.queue_size"] ?: "Queue"}: ${queueItems.size} \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.6f) + ) + } + } + } + } + if (activeTasks.isEmpty() && recentTasks.isEmpty()) { item { AphelionTasksEmptyState(translation["no_tasks"]) } } - items(activeTasks, key = { it.task.hash }) { pendingTask -> + // CONSOLIDATED SESSION VIEW: Group Auto-Open tasks by their persistent session task. + // Non-AutoOpen tasks (Downloads, etc.) remain as individual cards. + val groupedActiveTasks = activeTasks.distinctBy { it.task.hash } + + items(groupedActiveTasks, key = { it.task.hash }) { pendingTask -> + val isAutoOpen = pendingTask.task.isAutoOpen + val pulseAnimation = rememberInfiniteTransition(label = "pulse") + val pulseAlpha by pulseAnimation.animateFloat( + initialValue = 0.15f, + targetValue = 0.45f, + animationSpec = infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + TaskCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .let { + if (isAutoOpen) { + it.border( + width = 1.5.dp, + brush = Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha), + PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha) + ) + ), + shape = MaterialTheme.shapes.large + ) + } else it + }, task = pendingTask.task, pendingTask = pendingTask ) @@ -803,7 +887,13 @@ class TasksRootSection : Routes.Route() { if (isActive) { taskProgressLabel?.let { - Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White) + val labelText = if (task.isAutoOpen) { + // Live Metrics: Show Snaps/min and session total + val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0 // Placeholder for session start + val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0" + "$it • $speed snaps/min" + } else it + Text(labelText, style = MaterialTheme.typography.labelSmall, color = Color.White) } if (taskProgress != -1) { LinearProgressIndicator( @@ -842,4 +932,186 @@ class TasksRootSection : Routes.Route() { } } } + + @Composable + internal fun TaskTabSwitcher( + selectedTab: TaskTab, + onTabSelected: (TaskTab) -> Unit, + activeCount: Int, + scheduledCount: Int + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + TaskTab.entries.forEach { tab -> + val selected = selectedTab == tab + val count = if (tab == TaskTab.ACTIVE) activeCount else scheduledCount + Surface( + shape = RoundedCornerShape(18.dp), + color = if (selected) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f), + border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke( + 1.dp, + Color.White.copy(alpha = 0.12f) + ), + modifier = Modifier.weight(1f) + ) { + Row( + modifier = Modifier + .clickable { onTabSelected(tab) } + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (tab == TaskTab.ACTIVE) Icons.Filled.Timer else Icons.Filled.Schedule, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = if (tab == TaskTab.ACTIVE) (context.translation["tasks_tab_active"] ?: "Active") else (context.translation["tasks_tab_scheduled"] ?: "Scheduled"), + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + fontSize = 13.sp + ) + } + } + } + } + } + + @Composable + internal fun TasksHeader( + selectedTab: TaskTab, + onTabSelected: (TaskTab) -> Unit, + activeCount: Int, + scheduledCount: Int, + runningCount: Int, + subtitle: String, + onClear: () -> Unit, + onMerge: () -> Unit, + canMerge: Boolean + ) { + val haptic = LocalHapticFeedback.current + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp) + .padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()), + shape = RoundedCornerShape(26.dp), + color = Color.White.copy(alpha = 0.07f), + tonalElevation = 0.dp, + shadowElevation = 0.dp, + border = BorderStroke( + 1.dp, + Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = 0.55f), + PurrfectPalette.glowSecondary.copy(alpha = 0.35f) + ) + ) + ) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = context.translation["manager.routes.tasks"], + color = Color.White, + fontWeight = FontWeight.ExtraBold, + fontSize = 18.sp + ) + Text( + text = subtitle, + color = PurrfectPalette.textSecondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Row( + modifier = Modifier.wrapContentWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (canMerge) { + Surface( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMerge() + }, + shape = RoundedCornerShape(18.dp), + color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f), + border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Filled.Merge, contentDescription = context.translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp)) + Text(context.translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp) + } + } + } + + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.08f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Filled.PlaylistAddCheckCircle, + contentDescription = null, + tint = Color.White + ) + Text( + text = (context.translation["tasks_running_count"] ?: "{count} running") + .replace("{count}", runningCount.toString()), + color = Color.White, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp + ) + } + } + + IconButton(onClick = onClear) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = context.translation["tasks_clear_button_description"], + tint = Color.White + ) + } + } + } + + TaskTabSwitcher( + selectedTab = selectedTab, + onTabSelected = onTabSelected, + activeCount = activeCount, + scheduledCount = scheduledCount + ) + } + } + } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt index b5c7ad82..a161fe02 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionTasksView.kt @@ -58,7 +58,9 @@ import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent import me.eternal.purrfectsnap.ui.util.coil.cacheKey import me.eternal.purrfectsnap.ui.util.scaleOnPress import me.eternal.purrfectsnap.ui.util.Motion +import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection.TaskTab import me.eternal.purrfectsnap.ui.util.headerHeightTracker +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -117,7 +119,6 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { ) } - // The "Structured Glass" Container (Dynamically Morphed) Surface( modifier = Modifier .fillMaxSize() @@ -129,32 +130,226 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { shadowElevation = 0.dp, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) ) { - LazyColumn( - state = scrollState, - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 10.dp, - end = 10.dp, - top = controlsHeight - 44.dp, - bottom = routes.bottomPadding + 20.dp - ), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - item { - if (activeTasks.isEmpty() && recentTasks.isEmpty()) { - AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks") + Column(modifier = Modifier.fillMaxSize().padding(top = controlsHeight - 44.dp)) { + Surface( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(14.dp), + color = Color.White.copy(alpha = 0.05f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f)) + ) { + Row( + modifier = Modifier.padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + TaskTab.entries.forEach { tab -> + val isSelected = selectedTab == tab + val backgroundAlpha by animateFloatAsState(if (isSelected) 0.12f else 0f, label = "tabBg") + Box( + modifier = Modifier + .weight(1f) + .height(38.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = backgroundAlpha)) + .clickable { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + selectedTab = tab + }, + contentAlignment = Alignment.Center + ) { + Text( + text = if (tab == TaskTab.ACTIVE) (translation["tasks_tab_active"] ?: "Active") else (translation["tasks_tab_scheduled"] ?: "Scheduled"), + color = if (isSelected) Color.White else Color.White.copy(alpha = 0.5f), + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontSize = 13.sp + ) + } + } } } - items(activeTasks, key = { it.taskId }) { pendingTask -> - AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask) + + val activeList = activeTasks.filter { it.task.type != TaskType.SCHEDULED_SEND } + val recentList = recentTasks.filter { task -> + task.type != TaskType.SCHEDULED_SEND && activeList.none { it.task.hash == task.hash } } - items(recentTasks, key = { it.hash }) { task -> - AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task) + + val scheduledActive = activeTasks.filter { it.task.type == TaskType.SCHEDULED_SEND } + val scheduledRecent = recentTasks.filter { task -> + task.type == TaskType.SCHEDULED_SEND && scheduledActive.none { it.task.hash == task.hash } } - item { - Spacer(modifier = Modifier.height(40.dp)) - LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { - fetchNewRecentTasks() + + LazyColumn( + state = scrollState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 10.dp, + end = 10.dp, + top = 8.dp, + bottom = routes.bottomPadding + 20.dp + ), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (selectedTab == TaskTab.ACTIVE) { + item(key = "auto_open_card") { + var queueItems by remember { mutableStateOf(listOf()) } + var processedCount by remember { mutableIntStateOf(0) } + + LaunchedEffect(Unit) { + while (true) { + runCatching { + val autoOpen = context.bridgeService?.messagingBridge?.autoOpenInterface + processedCount = autoOpen?.processedCount ?: 0 + val items = autoOpen?.queueItems ?: emptyList() + queueItems = items.mapNotNull { + runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull() + } + } + delay(2000) + } + } + + val queueSize = queueItems.size + + if (queueSize > 0 || processedCount > 0) { + var isExpanded by remember { mutableStateOf(false) } + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + shape = RoundedCornerShape(18.dp), + color = Color.White.copy(alpha = 0.06f), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + isExpanded = !isExpanded + } + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.AutoFixHigh, null, tint = PurrfectPalette.glowSecondary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(10.dp)) + Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", fontWeight = FontWeight.Bold, color = Color.White) + } + Icon( + if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + null, + tint = Color.White.copy(alpha = 0.5f) + ) + } + Row(modifier = Modifier.padding(top = 4.dp, start = 30.dp)) { + Text( + "${translation["auto_open_snaps.queue_size"] ?: "Queue"}: $queueSize \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount", + fontSize = 12.sp, + color = Color.White.copy(alpha = 0.6f) + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column(modifier = Modifier.padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + queueItems.forEach { rawItem -> + val item = rawItem as? Map ?: return@forEach + Row( + modifier = Modifier.fillMaxWidth().background(Color.White.copy(alpha = 0.03f), RoundedCornerShape(8.dp)).padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(item["senderInfo"] ?: "", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium) + Text(item["contentType"] ?: "", fontSize = 11.sp, color = Color.White.copy(alpha = 0.5f)) + } + Text(item["conversationType"] ?: "", fontSize = 10.sp, color = PurrfectPalette.glowSecondary.copy(alpha = 0.7f)) + } + } + + if (processedCount > 0 || queueSize > 0) { + OutlinedButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + runCatching { context.bridgeService?.messagingBridge?.autoOpenInterface?.reset() } + }, + modifier = Modifier.fillMaxWidth().height(36.dp), + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, Color.Red.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red.copy(alpha = 0.7f)) + ) { + Text(translation["auto_open_snaps.action_reset"] ?: "Reset Statistics", fontSize = 12.sp) + } + } + } + } + } + } + } + } + + if (activeList.isEmpty() && recentList.isEmpty()) { + item(key = "active_empty") { AphelionTasksEmptyState(text = translation["tasks_no_active_tasks"] ?: "No active tasks") } + } + + val groupedActiveTasks = activeList.distinctBy { it.task.hash } + + items(groupedActiveTasks, key = { it.task.hash }) { pendingTask -> + val isAutoOpenTask = pendingTask.task.isAutoOpen + val pulseAnimation = rememberInfiniteTransition(label = "pulse") + val pulseAlpha by pulseAnimation.animateFloat( + initialValue = 0.15f, + targetValue = 0.45f, + animationSpec = infiniteRepeatable( + animation = tween(1200, easing = LinearEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + AphelionTaskCard( + modifier = Modifier + .fillMaxWidth() + .let { + if (isAutoOpenTask) { + it.border( + width = 1.5.dp, + brush = Brush.linearGradient( + listOf( + PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha), + PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha) + ) + ), + shape = RoundedCornerShape(22.dp) + ) + } else it + }, + task = pendingTask.task, + pendingTask = pendingTask + ) + } + + items(recentList.filter { task -> groupedActiveTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task -> + AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task) + } + } else { + if (scheduledActive.isEmpty() && scheduledRecent.isEmpty()) { + item(key = "scheduled_empty") { AphelionTasksEmptyState(text = translation["tasks_no_scheduled_tasks"] ?: "No scheduled snaps") } + } + + items(scheduledActive, key = { it.taskId }) { pendingTask -> + AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask) + } + items(scheduledRecent, key = { it.hash }) { task -> + AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task) + } + } + + item { + Spacer(modifier = Modifier.height(40.dp)) + LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { + fetchNewRecentTasks() + } } } } @@ -167,6 +362,35 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { enableMorph = true, modifier = Modifier.headerHeightTracker { controlsHeight = it }, actions = { + if (taskSelection.size > 1) { + val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) { + taskSelection.all { it.second?.type?.contains("video") == true } + } + + if (canMergeSelection) { + Surface( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + mergeSelection(taskSelection.toList().also { + taskSelection.clear() + }.map { it.first to it.second!! }) + }, + shape = RoundedCornerShape(18.dp), + color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f), + border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon(Icons.Filled.Merge, contentDescription = translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp)) + Text(translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp) + } + } + } + } + Surface( shape = RoundedCornerShape(18.dp), color = Color.White.copy(alpha = 0.08f), @@ -191,34 +415,12 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { ) } } - if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) { - Surface( - onClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - mergeSelection( - taskSelection.toList().also { taskSelection.clear() } - .map { it.first to it.second!! } - ) - }, - shape = RoundedCornerShape(18.dp), - color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f), - border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp)) - Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp) - } - } - } + IconButton(onClick = { haptic.performHapticFeedback(HapticFeedbackType.LongPress) showConfirmDialog = true }) { - Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White) + Icon(Icons.Filled.DeleteSweep, contentDescription = translation["tasks_clear_button_description"], tint = Color.White) } } ) @@ -227,11 +429,11 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) { if (showConfirmDialog) { val isSelection = taskSelection.isNotEmpty() val titleText = if (isSelection) { - translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString()) + translation.format("tasks_remove_selected_tasks_confirm", "count" to taskSelection.size.toString()) } else { - translation["remove_all_tasks_confirm"] + translation["tasks_remove_all_tasks_confirm"] } - val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"] + val messageText = if (isSelection) translation["tasks_remove_selected_tasks_title"] else translation["tasks_remove_all_tasks_title"] TaskDangerDialog( visible = showConfirmDialog, @@ -494,10 +696,19 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p } if (!taskStatus.isFinalStage()) { - if (!isActive) { + if (isActive) { + taskProgressLabel?.let { + val labelText = if (task.isAutoOpen) { + val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0 + val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0" + "$it • $speed snaps/min" + } else it + Text(labelText, style = MaterialTheme.typography.bodySmall, color = Color.White) + } + } else { taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) } } - if (taskProgress != -1 && taskProgressLabel == null) { + if (taskProgress != -1 && (taskProgressLabel == null || isActive)) { LinearProgressIndicator( progress = { taskProgress.toFloat() / 100f }, strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(), diff --git a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/AutoOpenInterface.aidl b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/AutoOpenInterface.aidl new file mode 100644 index 00000000..431dda34 --- /dev/null +++ b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/AutoOpenInterface.aidl @@ -0,0 +1,7 @@ +package me.eternal.purrfectsnap.bridge; + +interface AutoOpenInterface { + int getProcessedCount(); + List getQueueItems(); // returns JSON serialized SnapQueueItem list + void reset(); +} diff --git a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/snapclient/MessagingBridge.aidl b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/snapclient/MessagingBridge.aidl index 6b7a4a58..cdb4461a 100644 --- a/common/src/main/aidl/me/eternal/purrfectsnap/bridge/snapclient/MessagingBridge.aidl +++ b/common/src/main/aidl/me/eternal/purrfectsnap/bridge/snapclient/MessagingBridge.aidl @@ -20,4 +20,6 @@ interface MessagingBridge { @nullable String updateMessage(String conversationId, long clientMessageId, String messageUpdate); @nullable String getOneToOneConversationId(String userId); + + me.eternal.purrfectsnap.bridge.AutoOpenInterface getAutoOpenInterface(); } \ No newline at end of file diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 0f68f8ae..3e6df82c 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -1,4 +1,4 @@ -{ +{ "setup": { "activity": { "wrong_apk_title": "Wrong APK installed", @@ -1611,6 +1611,13 @@ } }, "auto_open_snaps": { + "title": "Auto Open Snaps", + "status_monitoring": "Monitoring", + "status_active": "Active", + "status_paused": "Paused", + "processed_count": "Opened", + "queue_size": "Queue", + "action_reset": "Reset Statistics", "name": "Auto Open Snaps Settings", "description": "Configure delay and queue settings for Auto Open Snaps", "properties": { @@ -1637,7 +1644,18 @@ "retry_delay": { "name": "Retry Delay (ms)", "description": "Delay in milliseconds between retry attempts" - } + }, + "compact_notification": { + "name": "Auto Open Compact Notification", + "description": "Use a smaller, single-line notification for status updates" + }, + "only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" }, + "only_when_idle": { + "name": "Auto Open only when Idle", + "description": "Only process queue when the device is not in active use" + }, + "pause_during_gaming": { "name": "Pause Auto Open During Gaming", "description": "Automatically slow down processing when a resource intensive app or a game is in the foreground" }, + "safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." } } }, "auto_delete_sent_messages": { @@ -1989,7 +2007,7 @@ "name": "HEVC Recording", "description": "Uses HEVC (H.265) codec for video recording" }, - "video_record_timer": { + "camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": { "name": "Video Recording Timer", "description": "Shows a recording timer overlay when recording video" } @@ -2127,7 +2145,7 @@ } } }, - "better_transcript": { + "network_optimization": { "name": "Network Optimization", "description": "Optimizes network socket buffers for higher throughput" }, "better_transcript": { "name": "Better Transcript", "description": "Improves the voice note transcript", "properties": { @@ -3199,9 +3217,7 @@ "export_failed_toast": "Failed to export account. Check logs for more info.", "forced_logout_toast": "Removed account due to forced logout" }, - "auto_open_snaps": { - "title": "Auto Open Snaps", - "priority_title": "Auto Open Snaps (Priority)", + "auto_open_snaps": { "title": "Auto Open Snaps", "processed_count": "Opened", "queue_size": "Queue", "action_reset": "Reset Statistics", "priority_title": "Auto Open Snaps (Priority)", "error_title": "Auto Open Snaps (Errors)", "channel_description": "Notifications for auto-opening snaps queue status", "priority_channel_description": "High priority notifications for auto-opening snaps", @@ -3768,4 +3784,30 @@ "openai": "OpenAI", "openrouter": "OpenRouter" } + , + "tasks_no_tasks": "No tasks", + "tasks_no_active_tasks": "No active tasks", + "tasks_no_scheduled_tasks": "No scheduled snaps", + "tasks_tab_active": "Active", + "tasks_tab_scheduled": "Scheduled", + "tasks_clear_button_description": "Clear tasks", + "tasks_delete_button": "Delete", + "tasks_merge_button": "Merge", + "tasks_summary_active": "{active} active · {recent} recent", + "tasks_summary_idle": "Idle · {recent} recent", + "tasks_running_count": "{count} running", + "tasks_tagline": "Monitor and manage background actions", + "tasks_failed_to_open_file": "Failed to open file", + "tasks_merge_files_toast": "Merging {count} files", + "tasks_remove_selected_tasks_title": "Are you sure you want to remove selected tasks?", + "tasks_remove_all_tasks_title": "Are you sure you want to remove all tasks?", + "tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?", + "tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history." } + + + + + + + diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Camera.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Camera.kt index a71db652..57c7fa0f 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Camera.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Camera.kt @@ -59,5 +59,8 @@ class Camera : ConfigContainer() { val overrideBackResolution get() = _overrideBackResolution val videoRecordTimer = boolean("video_record_timer") + val audioVideoOptimizations = boolean("audio_video", defaultValue = true) { requireRestart() } + val cameraOptimizations = boolean("camera_tweaks", defaultValue = false) { addNotices(FeatureNotice.UNSTABLE); requireRestart() } + val customResolution = string("custom_resolution") { addNotices(FeatureNotice.UNSTABLE); inputCheck = { it.matches(Regex("\\d+x\\d+")) } } } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt index de75eda1..55bd9b9c 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt @@ -68,6 +68,7 @@ class Experimental : ConfigContainer() { val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); } val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) } + val networkOptimization = boolean("network_optimization") { requireRestart() } val betterTranscript = container("better_transcript", BetterTranscriptConfig()) { requireRestart() } val voiceNoteAutoPlay = boolean("voice_note_auto_play") { requireRestart() } val friendNotes = boolean("friend_notes") { requireRestart() } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt index 60f61183..9bbc72b6 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt @@ -175,7 +175,13 @@ class MessagingTweaks : ConfigContainer() { val retryDelay = integer("retry_delay", defaultValue = 3000) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null } } - + val compactNotification = boolean("compact_notification", false) + + // Resource Intelligence: Smart triggers for battery and data safety + val onlyOnWifi = boolean("only_on_wifi", false) + val onlyWhenIdle = boolean("only_when_idle", false) + val pauseDuringGaming = boolean("pause_during_gaming", false) + val safeProcessing = boolean("safe_processing", true) } class AutoDeleteSentMessagesConfig : ConfigContainer(hasGlobalState = true) { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/Feature.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/Feature.kt index 7a7de781..544cced9 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/Feature.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/Feature.kt @@ -34,6 +34,8 @@ abstract class Feature( open fun init() {} + open fun onBridgeAction(action: String, extras: Map?, callback: (Any?) -> Unit) {} + protected fun findClass(name: String): Class<*> { return context.androidContext.classLoader.loadClass(name) 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 0b88a7d8..f9b41442 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 @@ -36,4 +36,20 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp } return state } + + override fun onBridgeAction(action: String, extras: Map?, callback: (Any?) -> Unit) { + if (action == "get_state") { + val conversationId = extras?.get("conversationId") as? String ?: return + callback(getState(conversationId)) + return + } + if (action == "set_state") { + val conversationId = extras?.get("conversationId") as? String ?: return + val state = extras["state"] as? Boolean ?: return + setState(conversationId, state) + callback(true) + return + } + super.onBridgeAction(action, extras, callback) + } } 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 23ff6bc3..8c368836 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 @@ -9,6 +9,11 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Build +import android.os.PowerManager +import android.app.ActivityManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import kotlinx.coroutines.isActive import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -26,16 +31,22 @@ import me.eternal.purrfectsnap.core.util.hook.hookConstructor import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.ConcurrentHashMap import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.random.Random +import me.eternal.purrfectsnap.bridge.AutoOpenInterface +import com.google.gson.Gson + class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) { companion object { const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_PAUSE_RESUME" const val ACTION_CLEAR_QUEUE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_CLEAR_QUEUE" } + private val gson = Gson() + data class SnapQueueItem( val conversationId: String, val messageId: Long, @@ -45,18 +56,38 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val timestamp: Long = System.currentTimeMillis() ) + private val autoOpenInterface = object : AutoOpenInterface.Stub() { + override fun getProcessedCount(): Int = totalProcessed + override fun getQueueItems(): List { + return synchronized(queuedSnaps) { + queuedSnaps.map { gson.toJson(it) } + } + } + override fun reset() { + synchronized(queuedSnaps) { + queuedSnaps.clear() + } + totalProcessed = 0 + updateStatusNotification() + } + } + + fun getInterface(): AutoOpenInterface = autoOpenInterface + private val snapQueue = MutableSharedFlow() - private var snapQueueSize = AtomicInteger(0) - private val openedSnaps = mutableListOf() + private val openedSnaps = ArrayDeque() private val isPaused = AtomicBoolean(false) - private val queuedSnaps = mutableListOf() - private var totalProcessed = AtomicInteger(0) + val queuedSnaps = mutableListOf() + var totalProcessed = 0 + private set - private var sessionStartTime = System.currentTimeMillis() + var sessionStartTime = System.currentTimeMillis() + private set private var lastResetTime = System.currentTimeMillis() - private var currentBatchSize = AtomicInteger(0) - private var currentBatchProcessed = AtomicInteger(0) + private var currentBatchSize = 0 + private var currentBatchProcessed = 0 + private var batchSnapCount = 0 // For jitter batch cooldown private val config by lazy { context.config.messaging.autoOpenSnaps } @@ -92,20 +123,20 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val feedbackContent = if (wasPaused) { this@AutoOpenSnaps.context.translation["auto_open_snaps.resumed_message"] } else { - this@AutoOpenSnaps.context.translation["auto_open_snaps.paused_message"].replace("{count}", snapQueueSize.get().toString()) + this@AutoOpenSnaps.context.translation["auto_open_snaps.paused_message"].replace("{count}", synchronized(queuedSnaps) { queuedSnaps.size }.toString()) } showTemporaryNotification(feedbackTitle, feedbackContent) updateStatusNotification() - if (wasPaused && snapQueueSize.get() > 0) { - this@AutoOpenSnaps.context.log.debug("Resumed with ${snapQueueSize.get()} snaps in queue") + if (wasPaused && synchronized(queuedSnaps) { queuedSnaps.size } > 0) { + this@AutoOpenSnaps.context.log.debug("[AUTO-OPEN] Resumed with ${synchronized(queuedSnaps) { queuedSnaps.size }} snaps in queue") } } ACTION_CLEAR_QUEUE -> { - val queueSize = snapQueueSize.get() - val processedCount = totalProcessed.get() + val queueSize = synchronized(queuedSnaps) { queuedSnaps.size } + val processedCount = totalProcessed clearQueue(resetTotalCount = true, showNotification = false) @@ -129,26 +160,25 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A notificationManager.createNotificationChannel( NotificationChannel(baseChannelId, context.translation["auto_open_snaps.title"], - NotificationManager.IMPORTANCE_LOW).apply { + // Visible Presence Fix: Upgrade importance to DEFAULT so it stays in status bar. + NotificationManager.IMPORTANCE_DEFAULT).apply { description = context.translation["auto_open_snaps.channel_description"] - setShowBadge(false) + setShowBadge(true) setSound(null, null) enableVibration(false) } ) - + notificationManager.createNotificationChannel( NotificationChannel(priorityChannelId, context.translation["auto_open_snaps.priority_title"], - NotificationManager.IMPORTANCE_DEFAULT).apply { + NotificationManager.IMPORTANCE_HIGH).apply { description = context.translation["auto_open_snaps.priority_channel_description"] setShowBadge(true) setSound(null, null) enableVibration(false) } ) - - } private fun createPendingIntent(action: String): PendingIntent { @@ -165,12 +195,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A private fun verifyQueueSync(): Boolean { return synchronized(queuedSnaps) { - val actualSize = queuedSnaps.size - val atomicSize = snapQueueSize.get() - val currentTime = System.currentTimeMillis() val timeoutMs = 5 * 60 * 1000L - val originalSize = queuedSnaps.size val removed = mutableListOf() queuedSnaps.removeAll { item -> @@ -180,23 +206,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } if (removed.isNotEmpty()) { - context.log.warn("Cleaned up ${removed.size} stuck items") + context.log.warn("[AUTO-OPEN] Cleaned up ${removed.size} stuck items") } - if (actualSize != atomicSize) { - context.log.warn("Queue size mismatch! Actual: $actualSize, Atomic: $atomicSize") - snapQueueSize.set(queuedSnaps.size) - - val uniqueItems = queuedSnaps.distinctBy { it.messageId }.toMutableList() - if (uniqueItems.size != queuedSnaps.size) { - context.log.warn("Found ${queuedSnaps.size - uniqueItems.size} duplicate items") - queuedSnaps.clear() - queuedSnaps.addAll(uniqueItems) - snapQueueSize.set(queuedSnaps.size) - } - return@synchronized false - } else { - snapQueueSize.set(queuedSnaps.size) + val uniqueItems = queuedSnaps.distinctBy { it.messageId }.toMutableList() + if (uniqueItems.size != queuedSnaps.size) { + context.log.warn("[AUTO-OPEN] Found ${queuedSnaps.size - uniqueItems.size} duplicate items") + queuedSnaps.clear() + queuedSnaps.addAll(uniqueItems) } return@synchronized true @@ -222,15 +239,25 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A lastNotificationUpdate.set(currentTime) updateStatusNotificationInternal() } - + private fun updateStatusNotificationInternal() { verifyQueueSync() - val queueCount = snapQueueSize.get() - val processed = totalProcessed.get() + val queueCount = synchronized(queuedSnaps) { queuedSnaps.size } + val processed = totalProcessed - if (queueCount <= 0 && processed <= 0) { - notificationManager.cancel(statusNotificationId) + // Self-Cleaning Logic: If work is done, wait 10s then auto-clear. + if (queueCount <= 0) { + if (processed > 0) { + context.coroutineScope.launch { + delay(10000) + if (synchronized(queuedSnaps) { queuedSnaps.size } <= 0) { + notificationManager.cancel(statusNotificationId) + } + } + } else { + notificationManager.cancel(statusNotificationId) + } return } @@ -258,10 +285,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A .setContentText(statusText) if (queueCount > 0) { - val batchSize = currentBatchSize.get() - val batchProcessed = currentBatchProcessed.get() - val progressMax = maxOf(batchSize, queueCount + batchProcessed) - val progressCurrent = batchProcessed + val progressMax = maxOf(currentBatchSize, queueCount + currentBatchProcessed) + val progressCurrent = currentBatchProcessed notificationBuilder.setProgress(progressMax, progressCurrent, false) @@ -299,7 +324,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A ) } - val recentSnaps = queuedSnaps.takeLast(5) + if (config.compactNotification.get()) { + notificationManager.notify(statusNotificationId, notificationBuilder.build()) + return + } + + val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) } val bigTextStyle = Notification.BigTextStyle() val detailText = buildString { @@ -352,7 +382,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val clearedCount = synchronized(queuedSnaps) { val count = queuedSnaps.size queuedSnaps.clear() - snapQueueSize.set(0) count } @@ -361,12 +390,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } if (resetTotalCount) { - totalProcessed.set(0) + totalProcessed = 0 lastResetTime = System.currentTimeMillis() } - currentBatchSize.set(0) - currentBatchProcessed.set(0) + currentBatchSize = 0 + currentBatchProcessed = 0 verifyQueueSync() @@ -376,7 +405,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val message = if (resetTotalCount) { context.translation["auto_open_snaps.queue_cleared"] } else { - context.translation["auto_open_snaps.notification_queue_cleared_opened"].replace("{opened}", totalProcessed.get().toString()) + context.translation["auto_open_snaps.notification_queue_cleared_opened"].replace("{opened}", totalProcessed.toString()) } showTemporaryNotification(context.translation["auto_open_snaps.queue_cleared_title"], message) } @@ -488,7 +517,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A synchronized(queuedSnaps) { if (!queuedSnaps.any { it.messageId == snapItem.messageId }) { queuedSnaps.add(snapItem) - snapQueueSize.set(queuedSnaps.size) wasAddedToPausedQueue = true updateStatusNotification() } @@ -497,26 +525,63 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A delay(2000) } - val queueSizeAfterRemoval = synchronized(queuedSnaps) { + synchronized(queuedSnaps) { queuedSnaps.removeAll { it.messageId == snapItem.messageId } - snapQueueSize.set(queuedSnaps.size) - queuedSnaps.size } - val minDelayMs = config.minDelay.get().toLong() - val maxDelayMs = config.maxDelayMs.get().toLong() - val delayMs = if (maxDelayMs > minDelayMs) { - Random.nextLong(minDelayMs, maxDelayMs) - } else { - minDelayMs + // RESOURCE AWARENESS + val connectivityManager = context.androidContext.getSystemService(ConnectivityManager::class.java) + val isWifi = connectivityManager?.activeNetwork?.let { + connectivityManager.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } == true + + val powerManager = context.androidContext.getSystemService(PowerManager::class.java) + val isIdle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) powerManager?.isDeviceIdleMode == true else false + + val activityManager = context.androidContext.getSystemService(ActivityManager::class.java) + val isGaming = activityManager?.runningAppProcesses?.firstOrNull { + it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + }?.processName?.let { name -> + !name.contains("snapchat") && !name.contains("purrfectsnap") + } ?: false + + // Immediate logging for visibility in [RESOURCE] filter + context.log.info("[RESOURCE] AutoOpen: Processing snap from ${snapItem.senderInfo}. Current state: WiFi=$isWifi, Idle=$isIdle, Gaming=$isGaming") + + while ( + (config.onlyOnWifi.get() && !isWifi) || + (config.onlyWhenIdle.get() && !isIdle) || + (config.pauseDuringGaming.get() && isGaming) + ) { + val waitTime = if (isGaming) 60000L else 5000L + context.log.warn("[RESOURCE] AutoOpen: Throttling queue due to resource constraints. Waiting ${waitTime}ms") + delay(waitTime) + if (!kotlin.coroutines.coroutineContext.isActive) return@collect } - delay(delayMs) + + // Stealth Pacing: Apply variable delays to remain undetected + if (config.safeProcessing.get()) { + batchSnapCount++ + val isRollingCooldown = batchSnapCount % 10 == 0 + val minDelayMs = if (isRollingCooldown) 5000L else config.minDelay.get().toLong() + val maxDelayMs = if (isRollingCooldown) 10000L else config.maxDelayMs.get().toLong() + + val jitter = if (maxDelayMs > minDelayMs) { + java.util.concurrent.ThreadLocalRandom.current().nextLong(minDelayMs, maxDelayMs) + } else minDelayMs + + context.log.verbose("[AUTO-OPEN] Stealth Pacing active. Waiting ${jitter}ms") + delay(jitter) + } else { + context.log.verbose("[AUTO-OPEN] Stealth Pacing disabled. Executing at maximum speed.") + } + var result: String? = null var lastError = "" for (i in 0 until config.retryAttempts.get()) { while ((!config.allowRunningInBackground.get() && context.isMainActivityPaused) || messaging.conversationManager == null) { - delay(2000) + delay(1000) } result = suspendCoroutine { continuation -> @@ -538,17 +603,16 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } if (result == null || result == "DUPLICATEREQUEST") { - totalProcessed.incrementAndGet() - currentBatchProcessed.incrementAndGet() + totalProcessed++ + currentBatchProcessed++ + context.log.verbose("[AUTO-OPEN] Successfully opened ${snapItem.contentType} from ${snapItem.senderInfo}") } else { - context.log.error("Failed to open ${snapItem.contentType} from ${snapItem.senderInfo}: $lastError") + context.log.error("[AUTO-OPEN] Failed to open ${snapItem.contentType} from ${snapItem.senderInfo}: $lastError") } - val finalQueueSize = snapQueueSize.get() - - if (finalQueueSize <= 0) { - currentBatchSize.set(0) - currentBatchProcessed.set(0) + if (synchronized(queuedSnaps) { queuedSnaps.size } <= 0) { + currentBatchSize = 0 + currentBatchProcessed = 0 } updateStatusNotification() @@ -576,7 +640,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A if (openedSnaps.contains(clientMessageId)) { return@launch } - openedSnaps.add(clientMessageId) + if (openedSnaps.size >= 500) openedSnaps.removeFirst() + openedSnaps.addLast(clientMessageId) } val senderId = event.message.senderId?.toString() ?: context.translation["auto_open_snaps.unknown_sender"] @@ -592,7 +657,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A contentType = contentType ) - val actualQueueSize = synchronized(queuedSnaps) { + synchronized(queuedSnaps) { val existingItem = queuedSnaps.find { it.messageId == snapItem.messageId } if (existingItem != null) { return@launch @@ -603,12 +668,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } queuedSnaps.add(snapItem) - snapQueueSize.set(queuedSnaps.size) - - val newSize = queuedSnaps.size - currentBatchSize.set(maxOf(currentBatchSize.get(), newSize)) - - queuedSnaps.size + currentBatchSize = maxOf(currentBatchSize, queuedSnaps.size) } updateStatusNotification() @@ -618,6 +678,23 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } } + override fun onBridgeAction(action: String, extras: Map?, callback: (Any?) -> Unit) { + if (action == "get_auto_open_status") { + val status = mutableMapOf() + status["processed"] = totalProcessed + status["queue"] = synchronized(queuedSnaps) { + queuedSnaps.map { item -> + mapOf( + "senderInfo" to item.senderInfo, + "contentType" to item.contentType, + "conversationType" to item.conversationType + ) + } + } + callback(status) + } + } + private fun getSenderDisplayName(senderId: String): String { return try { val friendInfo = context.database.getFriendInfo(senderId) 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 e8316ccb..5eeb7dbf 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 @@ -161,6 +161,7 @@ class DeviceSpooferHook: Feature("Device Spoofer") { val overridePlayStoreInstallerPackageName by context.config.experimental.spoof.overridePlayStoreInstallerPackageName val removeVpnTransportFlag by context.config.experimental.spoof.removeVpnTransportFlag val forceWifiTransportFlag by context.config.experimental.spoof.forceWifiTransportFlag + val networkOptimization by context.config.experimental.networkOptimization val spoofAndroidId by context.config.experimental.spoof.spoofDeviceId.spoofAndroidId if(overridePlayStoreInstallerPackageName) { @@ -270,6 +271,20 @@ class DeviceSpooferHook: Feature("Device Spoofer") { } } + if (networkOptimization) { + // Internal Buffer Optimization + findClass("java.net.Socket").apply { + hook("setSendBufferSize", HookStage.BEFORE) { param -> + val size = param.arg(0) + if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) // Force 1MB Buffer + } + hook("setReceiveBufferSize", HookStage.BEFORE) { param -> + val size = param.arg(0) + if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) // Force 1MB Buffer + } + } + } + if (spoofAndroidId) { val gsfId = getRandomGsfId() val wifiMac = generateRandomMacAddress() diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/CameraTweaks.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/CameraTweaks.kt index 49683aa6..b48168a1 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/CameraTweaks.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/CameraTweaks.kt @@ -5,6 +5,8 @@ import android.annotation.SuppressLint import android.content.ContextWrapper import android.content.pm.PackageManager import android.graphics.Bitmap +import android.media.MediaRecorder +import android.hardware.camera2.CaptureRequest import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraCharacteristics.Key import android.hardware.camera2.CameraManager @@ -27,6 +29,34 @@ class CameraTweaks : Feature("Camera Tweaks") { override fun init() { val config = context.config.camera + // Toggle A: Audio & Video Optimizations (Bitrates) + if (config.audioVideoOptimizations.get()) { + MediaRecorder::class.java.hook("setVideoEncodingBitRate", HookStage.BEFORE) { param -> + val currentRate = param.arg(0) + if (currentRate < 30_000_000) param.setArg(0, 30_000_000) + } + MediaRecorder::class.java.hook("setAudioEncodingBitRate", HookStage.BEFORE) { param -> + param.setArg(0, 320_000) + } + MediaRecorder::class.java.hook("setAudioSamplingRate", HookStage.BEFORE) { param -> + param.setArg(0, 48_000) + } + } + + // Toggle B: Camera Optimizations (Hardware ISP - UNSTABLE) + if (config.cameraOptimizations.get()) { + 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_HIGH_QUALITY) + CaptureRequest.NOISE_REDUCTION_MODE -> param.setArg(1, CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY) + CaptureRequest.HOT_PIXEL_MODE -> param.setArg(1, CaptureRequest.HOT_PIXEL_MODE_HIGH_QUALITY) + CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE -> param.setArg(1, CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE_HIGH_QUALITY) + CaptureRequest.CONTROL_AF_MODE -> param.setArg(1, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE) + } + } + } + val frontCameraId by lazy { runCatching { context.androidContext.getSystemService(CameraManager::class.java).run { cameraIdList.firstOrNull { getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT } 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 380ab81f..f3bb340b 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 @@ -52,7 +52,11 @@ class MessageIndicators : Feature("Message Indicators") { contentAlignment = Alignment.TopEnd ) { val hasEncryption by rememberAsyncMutableState(defaultValue = false) { - reader.getByteArray(4, 3, 3) != null || reader.containsPath(3, 99, 3) + // Strictly detect Private Fidelius Wrap (1-on-1 private snaps) + reader.containsPath(4, 4, 1, 1) || + reader.containsPath(4, 4, 1, 1, 1) || + reader.getByteArray(4, 3, 3) != null || + reader.containsPath(3, 99, 3) } 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 diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/messaging/CoreMessagingBridge.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/messaging/CoreMessagingBridge.kt index 09ad018a..85b91aa8 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/messaging/CoreMessagingBridge.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/messaging/CoreMessagingBridge.kt @@ -117,4 +117,8 @@ class CoreMessagingBridge( } override fun getOneToOneConversationId(userId: String) = context.database.getDMConversationId(userId) + + override fun getAutoOpenInterface(): me.eternal.purrfectsnap.bridge.AutoOpenInterface? { + return context.feature(me.eternal.purrfectsnap.core.features.impl.experiments.AutoOpenSnaps::class).getInterface() + } } \ No newline at end of file