6 Commits

Author SHA1 Message Date
ΞTΞRNAL
21fba253b1 v1.5.9 2026-03-28 12:57:15 +05:30
ΞTΞRNAL
00c5d60b7b v1.5.8 2026-03-28 02:42:33 +05:30
ΞTΞRNAL
21cd306132 fix(PR): QOL fixes & Optimizations by Kaladin
QOL fixes & Optimizations.
2026-03-28 01:40:24 +05:30
DarkKnight2122
0d42aed0ff feat: Auto Open optimization 2026-03-28 00:22:36 +05:30
DarkKnight2122
f8fdd1893f feat: Task page layout revamp 2026-03-28 00:22:36 +05:30
DarkKnight2122
a8e2148b26 feat: Camera, Audio, Video & Network Optimizations 2026-03-28 00:22:35 +05:30
28 changed files with 1356 additions and 384 deletions

View File

@@ -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))
}
}

View File

@@ -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 = {}

View File

@@ -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

View File

@@ -38,11 +38,13 @@ class TaskManager(
private val activeTasks = mutableMapOf<Long, PendingTask>()
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")

View File

@@ -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<PendingTask>())
internal var recentTasks = mutableStateListOf<Task>()
internal val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
@@ -201,15 +206,94 @@ class TasksRootSection : Routes.Route() {
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item(key = "auto_open_card") {
var queueItems by remember { mutableStateOf(listOf<Any>()) }
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
)
}
}
}
}

View File

@@ -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<Any>()) }
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<String, String> ?: 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(),

View File

@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.util
import android.content.Context
import android.view.MotionEvent
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
@@ -29,6 +30,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -56,6 +59,10 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient
import okhttp3.Request
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
@@ -63,6 +70,7 @@ import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Overlay
import java.io.File
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
@@ -606,6 +614,13 @@ class AlertDialogs(
}
}
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
fun dismissKeyboard() {
keyboardController?.hide()
focusManager.clearFocus(force = true)
}
mapView.value = remember {
Configuration.getInstance().apply {
@@ -642,12 +657,37 @@ class AlertDialogs(
overlays.add(object: Overlay() {
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
dismissKeyboard()
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
mapView.invalidate()
return true
}
})
overlays.add(MapEventsOverlay(object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
dismissKeyboard()
return false
}
override fun longPressHelper(p: GeoPoint?): Boolean {
dismissKeyboard()
return false
}
}))
addMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?): Boolean {
dismissKeyboard()
return false
}
override fun onZoom(event: ZoomEvent?): Boolean {
dismissKeyboard()
return false
}
})
overlays.add(marker.value)
}
}
@@ -702,6 +742,19 @@ class AlertDialogs(
var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
val resultsScrollState = rememberScrollState()
BackHandler {
val shouldDismissKeyboard = locationName.isNotEmpty() || addressResults.isNotEmpty()
if (shouldDismissKeyboard) {
dismissKeyboard()
locationName = ""
addressResults = emptyList()
searchJob?.cancel()
searchJob = null
} else {
dismiss()
}
}
suspend fun search() {
if (locationSearchProvider == "google_maps") {
// Google Maps Search
@@ -946,6 +999,7 @@ class AlertDialogs(
modifier = Modifier
.fillMaxWidth()
.clickable {
dismissKeyboard()
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
mapView.value?.controller?.setCenter(marker.value?.position)
mapView.value?.invalidate()

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.6").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("304").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.9").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("308").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
// Include version code so each release has a different hash; use random for uniqueness within same version.

View File

@@ -1,3 +1,21 @@
## v1.5.9
- New: Block Calls Feature!
- New: Unlock Zoom Limit Feature!
## v1.5.8
- New: Implemented 30Mbps Video and 320kbps/48kHz Audio bitrates(tq to Kaladin)
- Fix: Advanced hardware ISP processing modes for superior dynamic range and less noisy video footage(tq to Kaladin)
- Fix: Optimized 1MB socket buffers for high-speed throughput which should upload the video/snap faster then before(tq to Kaladin)
- New: Aphelion Task page went full layout overhaul with segmented "Active" and "Scheduled" tab views(tq to Kaladin)
- FixL Auto-Open Status Card: Restored real-time visibility of processed snaps and queue status(tq to Kaladin)
- New: Smart trickle which has background processing with resource awareness (Paused/Trickle/Normal states)(tq to Kaladin)
- New: Four different states for auto open processing so that the device can be usable with auto open in the background(tq to Kaladin)
- Fix: memory hogging of the auto open queues, now the device should run much faster without any lag or stuttures(tq to Kaladin)
- Fix: "dot in username issue" with the implementation of unique incrementing ((1).jpg), where previously it was overwriting the previous file(tq to Kaladin)
- Fix: Lock Indicator for the snaps, which should now accurately be working on all versions(tq to Kaladin)
- Fix: Pressing back while keyboard is open in spoofed coordinates would close the dialog
- Fix: Not able to scroll in the "Can't Login?" dialog
## v1.5.6
- New: Add "Can't login" hint directly in the login screen

View File

@@ -0,0 +1,7 @@
package me.eternal.purrfectsnap.bridge;
interface AutoOpenInterface {
int getProcessedCount();
List<String> getQueueItems(); // returns JSON serialized SnapQueueItem list
void reset();
}

View File

@@ -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();
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"activity": {
"wrong_apk_title": "Wrong APK installed",
@@ -1292,6 +1292,10 @@
"name": "Call Start Confirmation",
"description": "Shows a confirmation dialog when starting a call"
},
"block_calls": {
"name": "Block Calls",
"description": "Blocks Snapchat call session updates so call UI and incoming call overlays do not appear"
},
"unlimited_conversation_pinning": {
"name": "Unlimited Conversation Pinning",
"description": "Allows you to pin an unlimited amount of conversations locally"
@@ -1611,6 +1615,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 +1648,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,9 +2011,17 @@
"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"
},
"unlock_zoom_limit": {
"name": "Unlock Zoom Limit",
"description": "Overrides the max camera zoom Snapchat reads from the device"
},
"max_zoom_override": {
"name": "Max Zoom Override",
"description": "Maximum zoom ratio to report to Snapchat, for example 120"
}
}
},
@@ -2127,7 +2157,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 +3229,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 +3796,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."
}

View File

@@ -58,6 +58,15 @@ class Camera : ConfigContainer() {
val overrideFrontResolution get() = _overrideFrontResolution
val overrideBackResolution get() = _overrideBackResolution
val videoRecordTimer = boolean("video_record_timer")
val unlockZoomLimit = boolean("unlock_zoom_limit") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val maxZoomOverride = float("max_zoom_override", 120f) {
requireRestart()
addFlags(ConfigFlag.NO_TRANSLATE)
inputCheck = { (it.toFloatOrNull() ?: 0f) in 1f..500f }
}
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+")) } }
}

View File

@@ -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() }

View File

@@ -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) {
@@ -216,6 +222,7 @@ class MessagingTweaks : ConfigContainer() {
val disableReplayInFF = boolean("disable_replay_in_ff")
val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()}
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
val blockCalls = boolean("block_calls") { requireRestart() }
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() }
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",

View File

@@ -7,8 +7,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.HelpOutline
@@ -153,6 +155,8 @@ class SecurityFeatures(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
val scrollState = rememberScrollState()
Icon(
Icons.Filled.Info,
contentDescription = null,
@@ -169,41 +173,78 @@ class SecurityFeatures(
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Text(
text = context.translation["setup.mappings.notice_intro"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = "For non-rooted users:",
color = PurrfectOverlayPalette.textPrimary,
fontWeight = FontWeight.SemiBold
)
Text(
text = context.translation["setup.mappings.notice_step_1"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_step_2"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_step_3"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_rooted_title"],
color = PurrfectOverlayPalette.textPrimary,
fontWeight = FontWeight.SemiBold
)
Text(
text = context.translation["setup.mappings.notice_rooted_body"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(end = 10.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = context.translation["setup.mappings.notice_intro"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = "For non-rooted users:",
color = PurrfectOverlayPalette.textPrimary,
fontWeight = FontWeight.SemiBold
)
Text(
text = context.translation["setup.mappings.notice_step_1"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_step_2"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_step_3"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_rooted_title"],
color = PurrfectOverlayPalette.textPrimary,
fontWeight = FontWeight.SemiBold
)
Text(
text = context.translation["setup.mappings.notice_rooted_body"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
}
val maxScroll = scrollState.maxValue
val thumbRatio = if (maxScroll > 0) {
((scrollState.value.toFloat() / maxScroll.toFloat()) * 0.7f).coerceIn(0f, 0.7f)
} else 0f
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.width(4.dp)
.fillMaxHeight()
.background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(999.dp))
) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(if (maxScroll > 0) 0.28f else 1f)
.offset(y = (320.dp * thumbRatio))
.background(
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.75f),
RoundedCornerShape(999.dp)
)
)
}
}
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(

View File

@@ -34,6 +34,8 @@ abstract class Feature(
open fun init() {}
open fun onBridgeAction(action: String, extras: Map<String, Any>?, callback: (Any?) -> Unit) {}
protected fun findClass(name: String): Class<*> {
return context.androidContext.classLoader.loadClass(name)

View File

@@ -114,6 +114,7 @@ class FeatureManager(
HideFriendFeedEntry(),
RequerySqlite(),
RefreshFriendSuggestions(),
BlockCalls(),
CallButtonsOverride(),
SnapPreview(),
BypassScreenshotDetection(),

View File

@@ -36,4 +36,20 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
}
return state
}
override fun onBridgeAction(action: String, extras: Map<String, Any>?, 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)
}
}

View File

@@ -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<String> {
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<SnapQueueItem>()
private var snapQueueSize = AtomicInteger(0)
private val openedSnaps = mutableListOf<Long>()
private val openedSnaps = ArrayDeque<Long>()
private val isPaused = AtomicBoolean(false)
private val queuedSnaps = mutableListOf<SnapQueueItem>()
private var totalProcessed = AtomicInteger(0)
val queuedSnaps = mutableListOf<SnapQueueItem>()
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<Long>()
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<String, Any>?, callback: (Any?) -> Unit) {
if (action == "get_auto_open_status") {
val status = mutableMapOf<String, Any>()
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)

View File

@@ -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<Int>(0)
if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) // Force 1MB Buffer
}
hook("setReceiveBufferSize", HookStage.BEFORE) { param ->
val size = param.arg<Int>(0)
if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) // Force 1MB Buffer
}
}
}
if (spoofAndroidId) {
val gsfId = getRandomGsfId()
val wifiMac = generateRandomMacAddress()

View File

@@ -7,10 +7,10 @@ import me.eternal.purrfectsnap.core.util.hook.hook
class DisableTelecomFramework: Feature("Disable Telecom Framework") {
override fun init() {
if (!context.config.global.disableTelecomFramework.get()) return
if (!context.config.global.disableTelecomFramework.get() && !context.config.messaging.blockCalls.get()) return
ContextWrapper::class.java.hook("getSystemService", HookStage.BEFORE) { param ->
if (param.arg<Any>(0).toString() == "telecom") param.setResult(null)
}
}
}
}

View File

@@ -0,0 +1,171 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.Notification
import android.app.NotificationManager
import android.content.Intent
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import java.nio.ByteBuffer
class BlockCalls : Feature("Block Calls") {
private fun isBlockedCallNotificationType(type: String?): Boolean {
return type?.lowercase() in setOf(
"initiate_audio",
"initiate_video",
"abandon_audio",
"abandon_video"
)
}
private fun shouldBlockVolatilePayload(eventData: ProtoReader): Boolean {
val dump = eventData.toString().lowercase()
return listOf(
"\"calluuid\"",
"\"callaction\"",
"\"messagetype\":\"caller_push\"",
"\"messagetype\":\"streamer_data_v2\"",
"\"messagetype\":\"callee_push\"",
"\"messagetype\":\"caller_hangup\"",
"\"messagetype\":\"call_end\""
).any { it in dump }
}
override fun init() {
if (!context.config.messaging.blockCalls.get()) return
runCatching {
findClass("com.google.firebase.messaging.FirebaseMessagingService")
.methods
.first {
it.declaringClass.name == "com.google.firebase.messaging.FirebaseMessagingService" &&
it.returnType == Void::class.javaPrimitiveType &&
it.parameterCount == 1 &&
it.parameterTypes[0] == Intent::class.java
}
.hook(HookStage.BEFORE) { param ->
val intent = param.argNullable<Intent>(0) ?: return@hook
if (!isBlockedCallNotificationType(intent.getStringExtra("type"))) return@hook
context.log.verbose("Blocked Firebase call message ${intent.getStringExtra("type")}", "BlockCalls")
param.setResult(null)
}
}
runCatching {
NotificationManager::class.java.findRestrictedMethod { it.name == "notifyAsUser" }?.hook(HookStage.BEFORE) { param ->
val notification = param.argNullable<Notification>(2) ?: return@hook
val notificationType = notification.extras
?.getBundle("system_notification_extras")
?.getString("notification_type")
if (!isBlockedCallNotificationType(notificationType)) return@hook
context.log.verbose("Blocked call notification $notificationType", "BlockCalls")
param.setResult(null)
}
}
runCatching {
findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").hook("onReceive", HookStage.BEFORE) { param ->
val buffer = param.argNullable<ByteBuffer>(0) ?: return@hook
val duplicate = buffer.duplicate().apply { position(0) }
val bytes = ByteArray(duplicate.limit())
duplicate.get(bytes)
val reader = ProtoReader(bytes)
val eventType = reader.getString(1, 1) ?: return@hook
if (eventType != "volatile") return@hook
val eventData = reader.followPath(1, 2) ?: return@hook
if (!shouldBlockVolatilePayload(eventData)) return@hook
context.log.verbose("Blocked volatile call payload", "BlockCalls")
param.setResult(null)
}
}
val talkCoreNames = listOf(
"com.snapchat.talkcorev3.TalkCore\$CppProxy",
"com.snapchat.talkcorev4.TalkCore\$CppProxy",
"com.snapchat.talkcore.TalkCore\$CppProxy"
)
talkCoreNames.forEach { className ->
runCatching {
findClass(className).apply {
hook("updateTSCallingSession", HookStage.BEFORE) { param ->
val params = param.argNullable<Any>(0)
val conversationId = params?.getObjectFieldOrNull("mConversationId")?.toString()
val inCall = params?.getObjectFieldOrNull("mInCall") as? Boolean
context.log.verbose(
"Blocked talk session update inCall=$inCall convo=$conversationId",
"BlockCalls"
)
param.setResult(null)
}
hook("disposeTSCallingSession", HookStage.BEFORE) { param ->
context.log.verbose("Blocked talk session dispose", "BlockCalls")
param.setResult(null)
}
}
}
}
listOf(
"com.snapchat.talkcorev3.TSCallingStateUpdateParams",
"com.snapchat.talkcorev4.TSCallingStateUpdateParams",
"com.snapchat.talkcore.TSCallingStateUpdateParams"
).forEach { className ->
runCatching {
findClass(className).hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val inCall = instance.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hookConstructor
if (!inCall) return@hookConstructor
instance.setObjectField("mInCall", false)
context.log.verbose("Forced TSCallingStateUpdateParams.mInCall=false", "BlockCalls")
}
}
}
context.event.subscribe(AddViewEvent::class) { event ->
val viewName = event.viewClassName.lowercase()
val parentName = event.parent.javaClass.name.lowercase()
val exactCallUi = setOf(
"com.snap.talk.callviewwrapper",
"com.snap.talk.core.callcontainer"
)
val callUiParents = setOf(
"com.snap.talk.core.callcontainer"
)
if (viewName in exactCallUi || parentName in callUiParents) {
context.log.verbose(
"Suppressed view ${event.viewClassName} parent=${event.parent.javaClass.name}",
"BlockCalls"
)
event.view.hideViewCompletely()
return@subscribe
}
if (viewName.endsWith("callbuttonsview") ||
(viewName.contains("call") && (
viewName.contains("overlay") ||
viewName.contains("incoming") ||
viewName.contains("ringing") ||
viewName.contains("ringer")
))
) {
event.view.hideViewCompletely()
}
}
}
}

View File

@@ -80,12 +80,13 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
override fun init() {
val hideUiComponents by context.config.userInterface.hideUiComponents
val blockCalls = context.config.messaging.blockCalls.get()
val hideProfileCallButtons = hideUiComponents.contains("hide_profile_call_buttons")
val hideChatCallButtons = hideUiComponents.contains("hide_chat_call_buttons")
val hideProfileCallButtons = blockCalls || hideUiComponents.contains("hide_profile_call_buttons")
val hideChatCallButtons = blockCalls || hideUiComponents.contains("hide_chat_call_buttons")
val callStartConfirmation = context.config.messaging.callStartConfirmation.get()
if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation) return
if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation && !blockCalls) return
var actionSheetVideoCallButtonId = -1
var actionSheetAudioCallButtonId = -1
@@ -111,7 +112,7 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
}
onNextActivityCreate {
if (callStartConfirmation) {
if (callStartConfirmation || blockCalls) {
(runCatching { findClass("com.snap.valdi.views.ValdiRootView") }.getOrNull()
?: findClass("com.snap.composer.views.ComposerRootView"))
.hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
@@ -122,6 +123,10 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
if (childComposerView.children().count {
it::class.java == childComposerView::class.java
} != 2) return@hook
if (blockCalls) {
param.setResult(true)
return@hook
}
hookTouchEvent(param, param.arg(0)) {
param.invokeOriginal()
}
@@ -131,6 +136,10 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
val view = param.thisObject<View>().takeIf { it.id != -1 } ?: return@hook
if (view.id != actionSheetAudioCallButtonId && view.id != actionSheetVideoCallButtonId) return@hook
if (blockCalls) {
param.setResult(true)
return@hook
}
hookTouchEvent(param, param.arg(0)) {
arrayOf(
MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0),

View File

@@ -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<Int>(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<CaptureRequest.Key<*>>(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 }
@@ -105,6 +135,18 @@ class CameraTweaks : Feature("Camera Tweaks") {
}
}
if (config.unlockZoomLimit.get()) {
val maxZoom = config.maxZoomOverride.get().coerceAtLeast(1f)
when {
key == CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM -> {
param.setResult(maxZoom)
}
key.name == "android.control.zoomRatioRange" -> {
param.setResult(Range(1f, maxZoom))
}
}
}
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
val isFrontCamera = param.invokeOriginal(
arrayOf(CameraCharacteristics.LENS_FACING)

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.5.6
APP_VERSION_CODE=304
APP_VERSION_NAME=1.5.9
APP_VERSION_CODE=308
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c