fix(PR): Multiple fixes by Kaladin

- Auto Open Engine refactor and implemented the engine stop button in the notification card.
- Media downloader stabilization fixes including Batch download fix.
- Implemented a new log filtering menu.
- Cargo Dependency updated to latest stable versions.
- Minor bug fixes.
This commit is contained in:
ΞTΞRNAL
2026-04-14 17:39:18 +05:30
committed by GitHub
27 changed files with 1205 additions and 1505 deletions

View File

@@ -648,8 +648,41 @@ class DownloadProcessor (
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media, FileType.fromFile(media)) val mediaFileType = FileType.fromFile(media)
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia)) val overlayFileType = FileType.fromFile(overlayMedia)
val renamedMedia = renameFromFileType(media, mediaFileType)
val renamedOverlayMedia = renameFromFileType(overlayMedia, overlayFileType)
if (mediaFileType.isImage && overlayFileType.isImage) {
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))
val originalBitmap = BitmapFactory.decodeFile(renamedMedia.absolutePath) ?: throw Exception("Failed to decode original image")
val overlayBitmap = BitmapFactory.decodeFile(renamedOverlayMedia.absolutePath) ?: throw Exception("Failed to decode overlay image")
val mergedBitmap = me.eternal.purrfectsnap.core.util.media.PreviewUtils.mergeBitmapOverlay(originalBitmap, overlayBitmap)
val mergedImage: File = File.createTempFile("merged", "." + (mediaFileType.fileExtension ?: "jpg"))
val compressFormat = when (mediaFileType) {
FileType.PNG -> Bitmap.CompressFormat.PNG
FileType.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP
else -> Bitmap.CompressFormat.JPEG
}
mergedImage.outputStream().use {
mergedBitmap.compress(compressFormat, 100, it)
}
saveMediaToGallery(pendingTask, mergedImage, downloadMetadata)
mergedImage.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}.onFailure {
remoteSideContext.log.error("Failed to merge image overlay using Bitmap, falling back to FFmpeg", it)
}
}
val mergedOverlay: File = File.createTempFile("merged", ".mp4") val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching { runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension)) callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))

View File

@@ -90,6 +90,8 @@ class FFMpegProcessor(
) )
private val sharedExecutor = Executors.newSingleThreadExecutor()
private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> { private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> {
val stringBuilder = StringBuilder() val stringBuilder = StringBuilder()
arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
@@ -127,7 +129,7 @@ class FFMpegProcessor(
Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE
else -> return@logFunction else -> return@logFunction
}, log.message) }, log.message)
}, { onStatistics(it) }, Executors.newSingleThreadExecutor()) }, { onStatistics(it) }, sharedExecutor)
} }
suspend fun execute(args: Request) { suspend fun execute(args: Request) {
@@ -162,7 +164,7 @@ class FFMpegProcessor(
} }
Action.MERGE_OVERLAY -> { Action.MERGE_OVERLAY -> {
inputArguments += "-i" to args.overlay!!.absolutePath inputArguments += "-i" to args.overlay!!.absolutePath
outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)\""
} }
Action.CONVERSION -> { Action.CONVERSION -> {
if (ffmpegOptions.customAudioCodec.isEmpty()) { if (ffmpegOptions.customAudioCodec.isEmpty()) {
@@ -187,45 +189,47 @@ class FFMpegProcessor(
}.getOrNull()?.let { file to it } }.getOrNull()?.let { file to it }
} }
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) -> try {
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
}?.let { (_, r) -> r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to }?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
} ?: throw Exception("Failed to get video size") r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
val filterFirstPart = StringBuilder() val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder() val filterSecondPart = StringBuilder()
var containsNoSound = false var containsNoSound = false
filesInfo.forEachIndexed { index, (file, retriever) -> filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];") filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") { if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]") filterSecondPart.append("[v$index][$index:a]")
} else { } else {
containsNoSound = true containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}]") filterSecondPart.append("[v$index][${filesInfo.size}]")
}
inputArguments += "-i" to file
} }
inputArguments += "-i" to file
if (containsNoSound) {
inputArguments += "-f" to "lavfi"
inputArguments += "-t" to "0.1"
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
}
if (outputArguments["-c:a"] == "copy") {
outputArguments -= "-c:a"
}
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
} finally {
filesInfo.forEach { it.second.close() }
} }
if (containsNoSound) {
inputArguments += "-f" to "lavfi"
inputArguments += "-t" to "0.1"
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
}
if (outputArguments["-c:a"] == "copy") {
outputArguments -= "-c:a"
}
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
filesInfo.forEach { it.second.close() }
} }
Action.DOWNLOAD_AUDIO_STREAM -> { Action.DOWNLOAD_AUDIO_STREAM -> {
outputArguments.clear() outputArguments.clear()

View File

@@ -86,7 +86,8 @@ class AnnouncementCheckWorker(
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE) val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId) val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.mipmap.ic_launcher) .setSmallIcon(R.mipmap.ic_launcher_monochrome)
.setLargeIcon(android.graphics.BitmapFactory.decodeResource(appContext.resources, R.mipmap.ic_launcher))
.setContentTitle(title) .setContentTitle(title)
.setContentText(text) .setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)

View File

@@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Report import androidx.compose.material.icons.outlined.Report
@@ -170,6 +171,7 @@ class HomeLogs : Routes.Route() {
internal fun LogsFloatingBar( internal fun LogsFloatingBar(
isRefreshing: Boolean, isRefreshing: Boolean,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onFilter: () -> Unit,
onExport: () -> Unit, onExport: () -> Unit,
onClear: () -> Unit onClear: () -> Unit
) { ) {
@@ -222,6 +224,20 @@ class HomeLogs : Routes.Route() {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (isRefreshing) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
IconButton(onClick = onFilter) {
Icon(
imageVector = Icons.Filled.FilterList,
contentDescription = "Filter Logs",
tint = PurrfectPalette.glowSecondary
)
}
IconButton(onClick = onRefresh, enabled = !isRefreshing) { IconButton(onClick = onRefresh, enabled = !isRefreshing) {
Icon( Icon(
imageVector = Icons.Filled.Refresh, imageVector = Icons.Filled.Refresh,
@@ -457,7 +473,31 @@ class HomeLogs : Routes.Route() {
LogLevel.WARN -> Icons.Outlined.Warning LogLevel.WARN -> Icons.Outlined.Warning
} }
enum class LogCategory(val translationKey: String, val tags: List<String>) {
CORE("log_category_core", listOf("core", "hook", "module", "mappings")),
AUTO_OPEN("log_category_auto_open", listOf("autoopenengine", "autoopen")),
MEDIA("log_category_media", listOf("downloader", "ffmpeg", "media", "video")),
BRIDGE("log_category_bridge", listOf("messagingbridge", "bridge", "ipc")),
SYSTEM("log_category_system", listOf("systemguard", "thermal", "battery", "wakelock")),
TRACKER("log_category_tracker", listOf("tracker", "friendtracker"))
}
val enabledCategories = mutableStateMapOf<LogCategory, Boolean>().apply {
LogCategory.entries.forEach { put(it, true) }
}
internal fun getCategoryForLog(line: LogLine): LogCategory? {
val tag = line.tag.lowercase()
val message = line.message.lowercase()
return LogCategory.entries.find { category ->
category.tags.any { tag.contains(it) || message.contains("[$it]") }
}
}
internal fun shouldHideLog(line: LogLine): Boolean { internal fun shouldHideLog(line: LogLine): Boolean {
val category = getCategoryForLog(line)
if (category != null && enabledCategories[category] == false) return true
val message = line.message.lowercase() val message = line.message.lowercase()
val tag = line.tag.lowercase() val tag = line.tag.lowercase()
return message.startsWith("blocked ep") || return message.startsWith("blocked ep") ||

View File

@@ -2,27 +2,31 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.ui.util.headerHeightTracker import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import me.eternal.purrfectsnap.ui.util.Motion import me.eternal.purrfectsnap.ui.util.Motion
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -37,6 +41,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) } var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() } val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
var showFilterDialog by remember { mutableStateOf(false) }
fun refreshLogs() { fun refreshLogs() {
isRefreshing = true isRefreshing = true
@@ -69,6 +74,67 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
} }
} }
@Composable
fun LogFilterDialog() {
Dialog(onDismissRequest = { showFilterDialog = false }) {
PurrfectOverlayTheme {
PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
HomeLogs.LogCategory.entries.forEach { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
enabledCategories.keys.forEach { enabledCategories[it] = false }
enabledCategories[category] = true
refreshLogs()
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Checkbox(
checked = enabledCategories[category] == true,
onCheckedChange = { checked ->
enabledCategories[category] = checked
refreshLogs()
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White.copy(alpha = 0.4f),
checkmarkColor = Color.White
)
)
Text(
text = translation[category.translationKey] ?: category.name,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = { showFilterDialog = false },
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
) {
Text(translation["filter_logs_done_button"] ?: "Done")
}
}
}
}
}
}
}
if (showFilterDialog) {
LogFilterDialog()
}
LaunchedEffect(externalRefreshTick.value) { LaunchedEffect(externalRefreshTick.value) {
if (externalRefreshTick.value > 0) { if (externalRefreshTick.value > 0) {
refreshLogs() refreshLogs()
@@ -132,6 +198,9 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
color = Color.White color = Color.White
) )
} }
IconButton(onClick = { showFilterDialog = true }) {
Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary)
}
IconButton(onClick = { refreshLogs() }) { IconButton(onClick = { refreshLogs() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White) Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
} }

View File

@@ -1121,6 +1121,8 @@ object LegacyTheme : ThemeContract {
val visibleLogs = remember { mutableStateListOf<LogLine>() } val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor } val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
var showFilterDialog by remember { mutableStateOf(false) }
fun refreshLogs() { fun refreshLogs() {
coroutineScope.launch { coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) { val readerResult = withContext(Dispatchers.IO) {
@@ -1154,6 +1156,71 @@ object LegacyTheme : ThemeContract {
isRefreshing = false isRefreshing = false
} }
} }
@Composable
fun LogFilterDialog() {
androidx.compose.ui.window.Dialog(onDismissRequest = { showFilterDialog = false }) {
me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme {
me.eternal.purrfectsnap.core.ui.PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
HomeLogs.LogCategory.entries.forEach { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
// Solo Focus Logic: Tap the name to filter only this category
enabledCategories.keys.forEach { enabledCategories[it] = false }
enabledCategories[category] = true
isRefreshing = true
refreshLogs()
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Checkbox(
checked = enabledCategories[category] == true,
onCheckedChange = { checked ->
enabledCategories[category] = checked
isRefreshing = true
refreshLogs()
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White.copy(alpha = 0.4f),
checkmarkColor = Color.White
)
)
Text(
text = translation[category.translationKey] ?: category.name,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = { showFilterDialog = false },
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
) {
Text(translation["filter_logs_done_button"] ?: "Done")
}
}
}
}
}
}
}
if (showFilterDialog) {
LogFilterDialog()
}
LaunchedEffect(externalRefreshTick.intValue) { LaunchedEffect(externalRefreshTick.intValue) {
if (externalRefreshTick.intValue > 0) { if (externalRefreshTick.intValue > 0) {
isRefreshing = true isRefreshing = true
@@ -1181,6 +1248,7 @@ object LegacyTheme : ThemeContract {
isRefreshing = true isRefreshing = true
refreshLogs() refreshLogs()
}, },
onFilter = { showFilterDialog = true },
onExport = { exportLogs() }, onExport = { exportLogs() },
onClear = { clearLogsAndReload() } onClear = { clearLogsAndReload() }
) )

View File

@@ -256,6 +256,15 @@
"home_logs": { "home_logs": {
"no_logs_hint": "No logs available", "no_logs_hint": "No logs available",
"refresh_hint": "Pull to refresh or trigger an action to see new entries.", "refresh_hint": "Pull to refresh or trigger an action to see new entries.",
"filter_logs_title": "Filter Log Categories",
"filter_logs_menu_item": "Filter Logs",
"filter_logs_done_button": "Done",
"log_category_core": "Core",
"log_category_auto_open": "Auto-Open",
"log_category_media": "Media",
"log_category_bridge": "Bridge",
"log_category_system": "System",
"log_category_tracker": "Tracker",
"clear_logs_button": "Clear Logs", "clear_logs_button": "Clear Logs",
"export_logs_button": "Export Logs", "export_logs_button": "Export Logs",
"saving_logs_toast": "Saving logs, this may take a while ...", "saving_logs_toast": "Saving logs, this may take a while ...",
@@ -1676,6 +1685,14 @@
"name": "Allow Running in Background", "name": "Allow Running in Background",
"description": "Allows Auto Open Snaps to run in the background. Note: This will significantly drain your battery" "description": "Allows Auto Open Snaps to run in the background. Note: This will significantly drain your battery"
}, },
"delay_between_snaps": {
"name": "Delay Between Snaps",
"description": "The delay in milliseconds between opening each individual Snap"
},
"delay_between_conversations": {
"name": "Delay Between Conversations",
"description": "The delay in milliseconds when switching to open Snaps from a different conversation"
},
"min_delay": { "min_delay": {
"name": "Min Delay (ms)", "name": "Min Delay (ms)",
"description": "Minimum delay in milliseconds before opening a snap" "description": "Minimum delay in milliseconds before opening a snap"
@@ -2165,6 +2182,10 @@
"name": "Disable Bitmoji", "name": "Disable Bitmoji",
"description": "Disables Friends Profile Bitmoji" "description": "Disables Friends Profile Bitmoji"
}, },
"debug_font_redirect": {
"name": "Debug Native Font Redirect",
"description": "Logs native font interception. For developer use only."
},
"custom_emoji_font": { "custom_emoji_font": {
"name": "Custom Emoji Font", "name": "Custom Emoji Font",
"description": "Allows you to use a custom emoji font. Only works with .ttf fonts" "description": "Allows you to use a custom emoji font. Only works with .ttf fonts"
@@ -3725,6 +3746,7 @@
"snap_item": "Snap {index} of {total}" "snap_item": "Snap {index} of {total}"
}, },
"batch_download_complete_toast": "All snaps downloaded", "batch_download_complete_toast": "All snaps downloaded",
"batch_progress_toast": "Downloading {current}/{total}",
"batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible." "batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible."
}, },
"streaks_reminder": { "streaks_reminder": {
@@ -4385,8 +4407,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"openai": "OpenAI", "openai": "OpenAI",
"openrouter": "OpenRouter" "openrouter": "OpenRouter"
} },
,
"tasks_no_tasks": "No tasks", "tasks_no_tasks": "No tasks",
"tasks_no_active_tasks": "No active tasks", "tasks_no_active_tasks": "No active tasks",
"tasks_no_scheduled_tasks": "No scheduled snaps", "tasks_no_scheduled_tasks": "No scheduled snaps",
@@ -4395,8 +4416,8 @@
"tasks_clear_button_description": "Clear tasks", "tasks_clear_button_description": "Clear tasks",
"tasks_delete_button": "Delete", "tasks_delete_button": "Delete",
"tasks_merge_button": "Merge", "tasks_merge_button": "Merge",
"tasks_summary_active": "{active} active · {recent} recent", "tasks_summary_active": "{active} active \u2022 {recent} recent",
"tasks_summary_idle": "Idle · {recent} recent", "tasks_summary_idle": "Idle \u2022 {recent} recent",
"tasks_running_count": "{count} running", "tasks_running_count": "{count} running",
"tasks_tagline": "Monitor and manage background actions", "tasks_tagline": "Monitor and manage background actions",
"tasks_failed_to_open_file": "Failed to open file", "tasks_failed_to_open_file": "Failed to open file",

View File

@@ -9,9 +9,9 @@ class DownloaderConfig : ConfigContainer() {
val threads = integer("threads", 4) // Bump Default Value to 4 Tested on Pixel 5 (Qualcomm Snapdragon 765G) Had no lag val threads = integer("threads", 4) // Bump Default Value to 4 Tested on Pixel 5 (Qualcomm Snapdragon 765G) Had no lag
val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") { val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") {
addFlags(ConfigFlag.NO_TRANSLATE) addFlags(ConfigFlag.NO_TRANSLATE)
} }.apply { set("veryfast") }
val constantRateFactor = integer("constant_rate_factor", 30) val constantRateFactor = integer("constant_rate_factor", 22)
val videoBitrate = integer("video_bitrate", 5000) val videoBitrate = integer("video_bitrate", 8000)
val audioBitrate = integer("audio_bitrate", 128) val audioBitrate = integer("audio_bitrate", 128)
val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) }
val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) }

View File

@@ -35,6 +35,7 @@ class Experimental : ConfigContainer() {
class NativeHooks : ConfigContainer() { class NativeHooks : ConfigContainer() {
val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() } val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() }
val disableBitmoji = boolean("disable_bitmoji") val disableBitmoji = boolean("disable_bitmoji")
val debugFontRedirect = boolean("debug_font_redirect") { addFlags(ConfigFlag.HIDDEN) }
val customEmojiFont = string("custom_emoji_font") { val customEmojiFont = string("custom_emoji_font") {
requireRestart() requireRestart()
addFlags(ConfigFlag.USER_IMPORT) addFlags(ConfigFlag.USER_IMPORT)

View File

@@ -166,12 +166,18 @@ class MessagingTweaks : ConfigContainer() {
val maxDelayMs = integer("max_delay_ms", defaultValue = 100) { val maxDelayMs = integer("max_delay_ms", defaultValue = 100) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && it.toInt() > minDelay.get() } inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && it.toInt() > minDelay.get() }
} }
val queueSize = integer("queue_size", defaultValue = 1000) { val queueSize = integer("queue_size", defaultValue = 700) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null } inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
} }
val retryAttempts = integer("retry_attempts", defaultValue = 5) { val retryAttempts = integer("retry_attempts", defaultValue = 5) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null } inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
} }
val delayBetweenSnaps = integer("delay_between_snaps", defaultValue = 100) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null }
}
val delayBetweenConversations = integer("delay_between_conversations", defaultValue = 500) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null }
}
val retryDelay = integer("retry_delay", defaultValue = 3000) { val retryDelay = integer("retry_delay", defaultValue = 3000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null } inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
} }

View File

@@ -22,7 +22,7 @@ open class ScriptRuntime(
private val modules = mutableMapOf<String, JSModule>() private val modules = mutableMapOf<String, JSModule>()
fun eachModule(f: JSModule.() -> Unit) { open fun eachModule(f: JSModule.() -> Unit) {
modules.values.forEach { module -> modules.values.forEach { module ->
runCatching { runCatching {
module.f() module.f()

View File

@@ -59,12 +59,14 @@ fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFi
val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1]) val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1])
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
try { runCatching {
copyTo(fos) try {
} finally { copyTo(fos)
close() } finally {
fos.flush() close()
fos.close() fos.flush()
fos.close()
}
} }
} }

View File

@@ -164,10 +164,10 @@ class ModContext(
disableMetrics = config.global.disableMetrics.get(), disableMetrics = config.global.disableMetrics.get(),
valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true && valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q, Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
customEmojiFontPath = getCustomEmojiFontPath(this) customEmojiFontPath = getCustomEmojiFontPath(this),
) debugFontRedirect = config.experimental.nativeHooks.debugFontRedirect.get()
) )
} ) }
fun getConfigLocale(): String { fun getConfigLocale(): String {
return _config.locale return _config.locale

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.core package me.eternal.purrfectsnap.core
import me.eternal.purrfectsnap.common.scripting.JSModule
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent

View File

@@ -9,7 +9,6 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
@@ -18,23 +17,22 @@ import androidx.core.content.edit
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import me.eternal.purrfectsnap.bridge.AutoOpenInterface import me.eternal.purrfectsnap.bridge.AutoOpenInterface
import me.eternal.purrfectsnap.common.BuildConfig import me.eternal.purrfectsnap.common.config.PropertyValue
import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessageState import me.eternal.purrfectsnap.common.data.MessageState
import me.eternal.purrfectsnap.common.data.MessageUpdate import me.eternal.purrfectsnap.common.data.MessageUpdate
import me.eternal.purrfectsnap.common.data.MessagingRuleType import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.core.event.events.impl.BuildMessageEvent import me.eternal.purrfectsnap.core.event.events.impl.BuildMessageEvent
import me.eternal.purrfectsnap.core.wrapper.impl.Message
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.features.impl.tweaks.PerformanceMode
import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import java.util.* import java.util.*
import java.util.Objects
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -42,400 +40,326 @@ import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.random.Random import kotlin.random.Random
/**
* AutoOpenSnaps: High-performance engine with real-time diagnostics.
* Optimized for 20+ snaps/s with accurate stats and background resilience.
*/
class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) { class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) {
companion object { companion object {
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_PAUSE_RESUME" 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" const val ACTION_CLEAR_QUEUE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_CLEAR_QUEUE"
const val ACTION_STOP_ENGINE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_STOP_ENGINE"
private const val STATUS_NOTIFICATION_ID = 54321 private const val STATUS_NOTIFICATION_ID = 54321
private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN" private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN"
private const val PREF_TOTAL_OPENED = "auto_open_total_opened" private const val PREF_TOTAL_OPENED = "auto_open_total_opened"
private const val PREF_SESSION_START = "auto_open_session_start" private const val PREF_SESSION_START = "auto_open_session_start"
private const val PREF_SAVED_QUEUE = "auto_open_saved_queue"
private const val LAZY_SAVE_INTERVAL_MS = 600_000L
} }
private val gson = Gson() private val gson = Gson()
private val isPaused = AtomicBoolean(false) private val isPaused = AtomicBoolean(false)
private val totalProcessed = AtomicInteger(0) private val engineActive = AtomicBoolean(true)
private val sessionProcessed = AtomicInteger(0) private val totalProcessed = AtomicInteger(0)
private val sessionProcessed = AtomicInteger(0)
private val sessionStartTime = AtomicLong(System.currentTimeMillis()) private val sessionStartTime = AtomicLong(System.currentTimeMillis())
private val totalPausedDuration = AtomicLong(0)
private var lastPausedAt = AtomicLong(0)
private val averageProcessingTime = AtomicLong(800) private val averageProcessingTime = AtomicLong(800)
private val hasBeenActive = AtomicBoolean(false) private val lastSnapProcessedAt = AtomicLong(0)
private val isScreenOn = AtomicBoolean(true)
private val snapChannel = Channel<SnapQueueItem>(Channel.UNLIMITED)
private val openedSnapsIds = ConcurrentHashMap.newKeySet<Long>()
private val queuedSnaps = LinkedList<SnapQueueItem>()
private var engineJob: Job? = null
private val engineDispatcher = Dispatchers.Default.limitedParallelism(1)
private val snapQueue = MutableSharedFlow<Long>(extraBufferCapacity = 100) private val autoOpenConfig by lazy { this@AutoOpenSnaps.context.config.messaging.autoOpenSnaps }
private val openedSnaps = ConcurrentHashMap.newKeySet<Long>() private val notificationManager by lazy { this@AutoOpenSnaps.context.androidContext.getSystemService(NotificationManager::class.java) }
private val queuedSnaps = mutableListOf<SnapQueueItem>() private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) }
private val deadLetterQueue = mutableListOf<SnapQueueItem>() private val messaging by lazy { this@AutoOpenSnaps.context.feature(Messaging::class) }
private var wakeLock: PowerManager.WakeLock? = null
private val metadataCache = Collections.synchronizedMap(object : LinkedHashMap<String, String>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean = size > 500
})
private val config by lazy { context.config.messaging.autoOpenSnaps }
private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java) }
private val prefs by lazy { context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) }
private var lastConversationId: String? = null
private var currentStatusText = "Monitoring..." private var currentStatusText = "Monitoring..."
private var currentSpeedText = "Full Speed" private var currentSpeedText = "Full Speed"
private var isCurrentlyWaiting = false private var lastNotificationUpdate = 0L
private var wakeLock: PowerManager.WakeLock? = null
private var wakeLockCooldownJob: Job? = null
private var lastQueueActivity = System.currentTimeMillis()
private val lastNotificationUpdate = AtomicLong(0)
private val notificationUpdateDelay = 1000L private val notificationUpdateDelay = 1000L
private val pendingNotificationUpdate = AtomicBoolean(false) private val pendingNotificationUpdate = AtomicBoolean(false)
private val snapTimestamps = LinkedList<Long>() private val snapTimestamps = LinkedList<Long>()
private var lastConversationId: String? = null
private val isSaving = AtomicBoolean(false) private val isSaving = AtomicBoolean(false)
private val needsSaving = AtomicBoolean(false) private val needsSaving = AtomicBoolean(false)
private var isThermalThrottled = false private var isThermalThrottled = false
private var lastThermalThrottleAt = 0L private var lastThermalThrottleAt = 0L
private fun cancelStatusNotification() { private fun logInfo(msg: String) = this@AutoOpenSnaps.context.log.info("[AutoOpenEngine] $msg")
runCatching { notificationManager.cancel(STATUS_NOTIFICATION_ID) } private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg", e) else this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg")
}
data class SnapQueueItem(
val conversationId: String,
val messageId: Long,
val serverMessageId: Long?,
val senderId: String,
var senderName: String = "Pending...",
var conversationType: String = "Processing",
val contentType: String,
val timestamp: Long = System.currentTimeMillis()
)
private val autoOpenInterface = object : AutoOpenInterface.Stub() {
override fun getProcessedCount(): Int = sessionProcessed.get()
override fun getQueueItems(): List<String> = synchronized(queuedSnaps) { queuedSnaps.map { gson.toJson(it) } }
override fun reset() { clearInternalState() }
}
private fun clearInternalState() {
sessionProcessed.set(0)
totalProcessed.set(0)
totalPausedDuration.set(0)
lastPausedAt.set(0)
sessionStartTime.set(System.currentTimeMillis())
synchronized(queuedSnaps) { queuedSnaps.clear() }
synchronized(deadLetterQueue) { deadLetterQueue.clear() }
openedSnaps.clear()
prefs.edit()
.putLong(PREF_SESSION_START, System.currentTimeMillis())
.remove(PREF_SAVED_QUEUE)
.remove(PREF_TOTAL_OPENED)
.apply()
updateStatusNotification(force = true)
}
fun getSnapMetadata(clientMessageId: Long): SnapQueueItem? = synchronized(queuedSnaps) { queuedSnaps.find { it.messageId == clientMessageId } }
fun getInterface(): AutoOpenInterface = autoOpenInterface
private val actionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
ACTION_PAUSE_RESUME -> {
val paused = !isPaused.get()
isPaused.set(paused)
if (paused) lastPausedAt.set(System.currentTimeMillis())
else {
if (lastPausedAt.get() > 0) totalPausedDuration.addAndGet(System.currentTimeMillis() - lastPausedAt.get())
snapQueue.tryEmit(System.currentTimeMillis())
}
updateStatusNotification(force = true)
}
ACTION_CLEAR_QUEUE -> clearInternalState()
Intent.ACTION_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) }
Intent.ACTION_SCREEN_OFF -> isScreenOn.set(false)
}
}
}
override fun init() {
val messaging = context.feature(Messaging::class)
restorePersistence()
hasBeenActive.set(config.globalState == true)
if (config.allowRunningInBackground.get()) {
acquireWakeLock()
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
hook("appStateChanged", HookStage.BEFORE) { param ->
if (config.allowRunningInBackground.get()) {
val state = param.arg<Any>(0).toString()
if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null)
}
}
hookConstructor(HookStage.AFTER) { param ->
methods.firstOrNull { it.name == "appStateChanged" }?.let { method ->
val enumClass = method.parameterTypes[0]
val activeState = enumClass.enumConstants?.firstOrNull { it.toString() == "ACTIVE" || it.toString() == "FOREGROUND" }
if (activeState != null) method.invoke(param.thisObject<Any>(), activeState)
}
}
}
findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply {
hook("onAppForegrounded", HookStage.BEFORE) { param -> if (config.allowRunningInBackground.get()) param.setResult(null) }
hook("onAppBackgrounded", HookStage.BEFORE) { param -> if (config.allowRunningInBackground.get()) param.setResult(null) }
}
}
createNotificationChannels()
val filter = IntentFilter().apply {
addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(Intent.ACTION_BATTERY_CHANGED); addAction(Intent.ACTION_SCREEN_ON); addAction(Intent.ACTION_SCREEN_OFF)
}
val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_BATTERY_CHANGED && config.thermalProtection.get()) {
val temp = intent.getIntExtra("temperature", 0) / 10f
if (temp >= 40f && !isThermalThrottled) {
isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis()
} else if (isThermalThrottled && temp <= 36f && System.currentTimeMillis() - lastThermalThrottleAt > 600000) {
isThermalThrottled = false
}
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
context.androidContext.registerReceiver(batteryReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.androidContext.registerReceiver(actionReceiver, filter)
context.androidContext.registerReceiver(batteryReceiver, filter)
}
if (synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) snapQueue.tryEmit(System.currentTimeMillis())
// Watchdog Loop
context.coroutineScope.launch(Dispatchers.Default) {
while (isActive) {
if (config.globalState != true) { shutdownFeature(); break }
val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size }
if (remainingCount > 0) {
lastQueueActivity = System.currentTimeMillis(); acquireWakeLock()
if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis())
} else {
if (!isPaused.get() && System.currentTimeMillis() - lastQueueActivity > 300000) {
val revived = synchronized(deadLetterQueue) { if (deadLetterQueue.isNotEmpty()) deadLetterQueue.removeAt(0) else null }
if (revived != null) { synchronized(queuedSnaps) { queuedSnaps.add(revived) }; snapQueue.tryEmit(System.currentTimeMillis()) }
}
if (System.currentTimeMillis() - lastQueueActivity > 300000) {
startWakeLockCooldown()
}
}
updateStatusNotification()
delay(5000)
}
}
// Processing Loop
context.coroutineScope.launch(Dispatchers.Default) {
snapQueue.collect {
if (isPaused.get() || config.globalState != true) return@collect
while (isActive && config.globalState == true) {
val item = synchronized(queuedSnaps) { if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null } ?: break
var resourceWaiting = true
while (resourceWaiting) {
if (config.globalState != true || isPaused.get()) break
val isWifi = isWifiConnected()
val isIdle = isDeviceIdle()
val onlyIdle = config.onlyWhenIdle.get()
val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false
when {
config.onlyOnWifi.get() && !isWifi -> {
currentStatusText = "Waiting for WiFi..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000)
}
onlyIdle && !isIdle && !inSleepWindow -> {
currentStatusText = "Waiting for idle..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000)
}
else -> {
resourceWaiting = false;
val thermalActive = config.thermalProtection.get() && isThermalThrottled
currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed"
}
}
if (resourceWaiting) updateStatusNotification()
}
if (isPaused.get() || config.globalState != true) { synchronized(queuedSnaps) { queuedSnaps.add(0, item) }; continue }
isCurrentlyWaiting = false
// TIMING: 40ms switch
if (lastConversationId != null && lastConversationId != item.conversationId) { delay(40) }
lastConversationId = item.conversationId
currentStatusText = "Active"; updateStatusNotification()
var success = false
val startTime = System.currentTimeMillis()
var currentRetryDelay = config.retryDelay.get().toLong()
for (i in 0 until config.retryAttempts.get()) {
if (isPaused.get() || config.globalState != true) break
// Bridge Handshake
if (messaging.conversationManager == null) {
runCatching { context.messagingBridge.triggerSessionStart() }
var waitTime = 0
while (messaging.conversationManager == null && waitTime < 2000) { delay(100); waitTime += 100 }
}
success = performOpen(messaging, item)
if (success) {
sessionProcessed.incrementAndGet()
totalProcessed.incrementAndGet()
synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 100) snapTimestamps.removeFirst() }
val duration = System.currentTimeMillis() - startTime
averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong())
delay(5)
break
}
if (i < config.retryAttempts.get() - 1) {
currentStatusText = "Retrying..."; updateStatusNotification(); delay(currentRetryDelay); currentRetryDelay *= 2
}
}
if (!success && !isPaused.get()) {
currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification()
synchronized(openedSnaps) { openedSnaps.remove(item.messageId) }
synchronized(deadLetterQueue) { if (deadLetterQueue.size < 100) deadLetterQueue.add(item) else { deadLetterQueue.removeAt(0); deadLetterQueue.add(item) } }
}
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
currentStatusText = "Monitoring..."; updateStatusNotification()
delay(50)
}
}
}
}
// Global Detector
context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
// GLOBAL SILENCE GUARD
if (config.globalState != true) return@subscribe
val message = event.message
if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == context.database.myUserId) return@subscribe
val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe
val serverMsgId = message.orderKey
val contentType = message.messageContent?.contentType
if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe
if (config.globalState != true) return@subscribe
// Whitelist Resilience: Robust rule check
val ruleState = context.config.rules.getRuleState(ruleType)
val isWhitelisted = getState(conversationId)
val canProcess = if (ruleState == me.eternal.purrfectsnap.common.data.RuleState.BLACKLIST) !isWhitelisted else isWhitelisted
if (!canProcess) return@subscribe
acquireWakeLock()
synchronized(openedSnaps) {
if (openedSnaps.contains(clientMessageId)) return@subscribe
openedSnaps.add(clientMessageId)
if (openedSnaps.size > 5000) openedSnaps.clear()
}
val senderId = message.senderId?.toString() ?: "unknown"
val item = SnapQueueItem(conversationId, clientMessageId, serverMsgId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
synchronized(queuedSnaps) {
if (queuedSnaps.size >= config.queueSize.get()) queuedSnaps.removeFirstOrNull()
queuedSnaps.add(item)
}
if (context.config.messaging.preFetchSnaps.get()) {
runCatching { messaging.conversationManager?.fetchMessage(conversationId, clientMessageId, {}, {}) }
}
if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis())
updateStatusNotification()
triggerLazySave()
}
}
private suspend fun performOpen(messaging: Messaging, item: SnapQueueItem): Boolean = withContext(Dispatchers.IO) {
val manager = messaging.conversationManager ?: return@withContext false
suspendCancellableCoroutine<Boolean> { cont ->
runCatching {
manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result ->
if (result == null || result == "DUPLICATEREQUEST") {
cont.resume(true)
} else if (item.serverMessageId != null) {
manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult ->
cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST")
}
} else {
cont.resume(false)
}
}
}.onFailure { cont.resume(false) }
}
}
private fun getSnapsPerSecond(): Double { private fun getSnapsPerSecond(): Double {
val now = System.currentTimeMillis(); val window = 5000L val now = System.currentTimeMillis(); val window = 5000L
synchronized(snapTimestamps) { synchronized(snapTimestamps) {
snapTimestamps.removeIf { now - it > window }; return (snapTimestamps.size.toDouble() / (window / 1000.0)) snapTimestamps.removeIf { now - it > window }
// Smoother calculation for high-frequency bursts
return if (snapTimestamps.isEmpty()) 0.0 else (snapTimestamps.size.toDouble() / (window / 1000.0))
}
}
private fun formatDuration(m: Long): String {
val s = (m / 1000) % 60; val min = (m / 60000) % 60; val h = m / 3600000
return when { h > 0 -> "${h}h ${min}m"; min > 0 -> "${min}m ${s}s"; else -> "${s}s" }
}
override fun init() {
restorePersistence()
createNotificationChannels()
// NATIVE HOOKS: Ensuring Snapchat never sees the app as "In Background"
if ((autoOpenConfig.allowRunningInBackground as PropertyValue<Boolean>).get()) {
runCatching {
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
hook("appStateChanged", HookStage.BEFORE) { param ->
val state = param.arg<Any>(0).toString()
if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null)
}
}
findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply {
hook("onAppForegrounded", HookStage.BEFORE) { param -> param.setResult(null) }
hook("onAppBackgrounded", HookStage.BEFORE) { param -> param.setResult(null) }
}
}
}
setupReceivers()
startEngineWorker()
setupDetector()
}
private fun startEngineWorker() {
engineJob = this@AutoOpenSnaps.context.coroutineScope.launch(engineDispatcher) {
while (engineActive.get()) {
val item = try { snapChannel.receive() } catch (e: Exception) { break }
while (isPaused.get() && engineActive.get()) {
currentStatusText = "Paused"; updateStatusNotification(); delay(500)
}
if (!engineActive.get()) break
updateStatusNotification()
if (!validateEnvironmentalConstraints()) {
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
continue
}
// SPEED OPTIMIZATION: Instant switch (40ms) when stealth is off
val isSafe = (autoOpenConfig.safeProcessing as PropertyValue<Boolean>).get()
if (lastConversationId != null && lastConversationId != item.conversationId) {
delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue<Int>).get().toLong() else 40L)
}
lastConversationId = item.conversationId
processSnapItem(item)
lastSnapProcessedAt.set(System.currentTimeMillis())
// HIGH SPEED: 10ms floor for 20+ snaps/s
val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue<Int>).get().toLong()
if (isSafe) {
delay(Random.nextLong(baseDelay, baseDelay + 200))
} else {
delay(baseDelay.coerceAtMost(10))
}
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
currentStatusText = "Monitoring..."
updateStatusNotification()
}
}
}
}
private suspend fun processSnapItem(item: SnapQueueItem) {
currentStatusText = "Active"; updateStatusNotification()
var success = false
val startTime = System.currentTimeMillis()
for (i in 0 until (autoOpenConfig.retryAttempts as PropertyValue<Int>).get()) {
if (isPaused.get() || !engineActive.get() || autoOpenConfig.globalState == false) break
if (messaging.conversationManager == null) {
runCatching { this@AutoOpenSnaps.context.messagingBridge.triggerSessionStart() }
delay(1000)
}
success = withContext(Dispatchers.IO) { performOpen(item) }
if (success) {
// IMPORTANT: Item only removed after successful processing to ensure Stats sync
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp()
val duration = System.currentTimeMillis() - startTime
averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong())
triggerLazySave(); break
}
delay((autoOpenConfig.retryDelay as PropertyValue<Int>).get().toLong())
}
if (!success && !isPaused.get() && engineActive.get()) {
logError("Engine failed to open Snap: ${item.messageId}")
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification()
openedSnapsIds.remove(item.messageId)
}
}
private suspend fun performOpen(item: SnapQueueItem): Boolean {
val manager = messaging.conversationManager ?: return false
return suspendCancellableCoroutine { cont ->
runCatching {
manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result ->
if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) }
else if (item.serverMessageId != 0L) {
manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult ->
cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST")
}
} else { cont.resume(false) }
}
}.onFailure { logError("Bridge Error", it); cont.resume(false) }
}
}
private suspend fun validateEnvironmentalConstraints(): Boolean {
while (engineActive.get()) {
if (autoOpenConfig.globalState == false || isPaused.get()) return false
val isWifi = isWifiConnected()
val isIdle = isDeviceIdle()
val onlyIdle = (autoOpenConfig.onlyWhenIdle as PropertyValue<Boolean>).get()
val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false
val wifiStop = (autoOpenConfig.onlyOnWifi as PropertyValue<Boolean>).get() && !isWifi
val idleStop = onlyIdle && !isIdle && !inSleepWindow
when {
wifiStop -> { currentStatusText = "Waiting for WiFi..."; delay(5000) }
idleStop -> { currentStatusText = "Waiting for Idle..."; delay(5000) }
else -> {
val thermalActive = (autoOpenConfig.thermalProtection as PropertyValue<Boolean>).get() && isThermalThrottled
currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed"
return true
}
}
updateStatusNotification()
}
return false
}
private fun setupDetector() {
this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe
val message = event.message
if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe
val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe
val serverMessageId = message.orderKey ?: 0L
val contentType = message.messageContent?.contentType
if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe
if (!canUseRule(conversationId)) return@subscribe
if (openedSnapsIds.contains(clientMessageId)) return@subscribe
openedSnapsIds.add(clientMessageId)
val senderId = message.senderId?.toString() ?: "unknown"
val item = SnapQueueItem(conversationId, clientMessageId, serverMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
synchronized(queuedSnaps) { queuedSnaps.add(item) }
snapChannel.trySend(item)
acquireWakeLock(); updateStatusNotification(); triggerLazySave()
}
}
private fun triggerLazySave() {
needsSaving.set(true)
if (isSaving.compareAndSet(false, true)) {
this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.IO) {
while (needsSaving.get() && engineActive.get()) {
needsSaving.set(false); saveQueueToDisk(); delay(LAZY_SAVE_INTERVAL_MS)
}
isSaving.set(false)
}
}
}
private fun saveQueueToDisk() {
prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) }
}
private fun restorePersistence() {
val savedStartTime = prefs.getLong(PREF_SESSION_START, 0)
if (System.currentTimeMillis() - savedStartTime > 21600000) return
totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime)
}
private fun isWifiConnected(): Boolean {
val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return cm.getNetworkCapabilities(cm.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}
private fun isDeviceIdle(): Boolean = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode
private fun isInsideSleepWindow(): Boolean {
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
return hour >= 23 || hour <= 6
}
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
wakeLock = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { acquire(8 * 60 * 60 * 1000L) }
}
private fun releaseWakeLock() { if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null }
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(NotificationChannel("auto_open_status", "Auto-Open Status", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) })
} }
} }
private fun updateStatusNotification(force: Boolean = false) { private fun updateStatusNotification(force: Boolean = false) {
val currentTime = System.currentTimeMillis(); val lastUpdate = lastNotificationUpdate.get() val now = System.currentTimeMillis()
val remaining = synchronized(queuedSnaps) { queuedSnaps.size } if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) {
if (!isScreenOn.get() && !force) return
if (!force && (currentTime - lastUpdate) < notificationUpdateDelay) {
if (pendingNotificationUpdate.compareAndSet(false, true)) { if (pendingNotificationUpdate.compareAndSet(false, true)) {
context.coroutineScope.launch { delay(notificationUpdateDelay - (currentTime - lastUpdate)); pendingNotificationUpdate.set(false); updateStatusNotificationInternal() } this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() }
} }
return return
} }
lastNotificationUpdate.set(currentTime); updateStatusNotificationInternal() updateStatusNotificationInternal()
} }
private var lastNotificationStateHash: Int = 0
private fun updateStatusNotificationInternal() { private fun updateStatusNotificationInternal() {
if (!engineActive.get()) return
val processed = sessionProcessed.get() val processed = sessionProcessed.get()
val total = totalProcessed.get() val total = totalProcessed.get()
val remaining = synchronized(queuedSnaps) { queuedSnaps.size } val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
val currentStateHash = Objects.hash(processed, total, remaining, currentStatusText, isPaused.get())
if (currentStateHash == lastNotificationStateHash && remaining == 0) return
lastNotificationStateHash = currentStateHash
if (total <= 0 && remaining <= 0 && processed <= 0) return
val isWorking = remaining > 0 val isWorking = remaining > 0
val isCompact = config.compactNotification.get() == true val speed = if (isWorking) getSnapsPerSecond() else 0.0
lastNotificationUpdate = System.currentTimeMillis(); pendingNotificationUpdate.set(false)
val sessionTotal = processed + remaining val sessionTotal = processed + remaining
val speed = getSnapsPerSecond()
val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0 val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0
val eta = if (isWorking && !isCurrentlyWaiting && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..." val eta = if (isWorking && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..."
val builder = Notification.Builder(context.androidContext, "auto_open_snaps")
.setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play)
.setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY).setGroupSummary(false)
val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status")
.setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY)
// ICON LOGIC: Pause, Monitoring (Sync), or Active (Play)
val iconRes = when {
isPaused.get() -> android.R.drawable.ic_media_pause
!isWorking -> android.R.drawable.ic_popup_sync
else -> android.R.drawable.ic_media_play
}
builder.setSmallIcon(iconRes)
builder.setContentTitle("Auto-Open: $currentStatusText") builder.setContentTitle("Auto-Open: $currentStatusText")
val isCompact = (autoOpenConfig.compactNotification as PropertyValue<Boolean>).get()
if (isWorking) { if (isWorking) {
builder.setContentText("Opened: $processed │ Queue: $remaining") builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)")
builder.setSubText("$progressPercent% • Ends in: ${eta ?: "..."}") builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta")
builder.setProgress(sessionTotal, processed, false) builder.setProgress(sessionTotal, processed, false)
} else { } else {
builder.setContentText("$processed Opened Today │ $total Total") builder.setContentText("$processed Opened Today │ $total Total")
@@ -444,7 +368,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
} }
builder.addAction(Notification.Action.Builder(null, if (isPaused.get()) "Resume" else "Pause", createPendingIntent(ACTION_PAUSE_RESUME)).build()) builder.addAction(Notification.Action.Builder(null, if (isPaused.get()) "Resume" else "Pause", createPendingIntent(ACTION_PAUSE_RESUME)).build())
builder.addAction(Notification.Action.Builder(null, "Clear Queue", createPendingIntent(ACTION_CLEAR_QUEUE)).build()) builder.addAction(Notification.Action.Builder(null, "Clear", createPendingIntent(ACTION_CLEAR_QUEUE)).build())
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP_ENGINE)).build())
if (!isCompact) { if (!isCompact) {
val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) } val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) }
@@ -452,16 +377,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
val detailText = buildString { val detailText = buildString {
append("QUEUE STATISTICS\n") append("QUEUE STATISTICS\n")
append("├─ Opened: $processed snaps\n") append("├─ Opened: $processed snaps\n")
append("├─ Queue: $remaining snaps\n") append("├─ Queue: $remaining snaps • Ends in: $eta\n")
append("├─ Total Opened: $total snaps\n") if ((autoOpenConfig.showLifetimeStats as PropertyValue<Boolean>).get()) {
val speedNotion = if (remaining > 0) currentSpeedText else "Idle" append("├─ Total Opened: $total snaps\n")
val speedValue = if (remaining > 0) "${String.format("%.1f", speed)}/s" else "0.0/s" }
append("└─ Speed: $speedNotion ($speedValue)\n\n") val speedNotion = if (isWorking) currentSpeedText else "Idle"
val speedValue = "${String.format(Locale.US, "%.1f", speed)}/s"
append("└─ Speed: $speedNotion ($speedValue)\n")
if ((autoOpenConfig.showQueuePreview as PropertyValue<Boolean>).get()) {
if (config.showQueuePreview.get()) { append("\nQUEUE PREVIEW\n")
append("\n\nQUEUE PREVIEW\n") if (isWorking && remaining > 0) {
if (isWorking) {
recentSnaps.reversed().forEach { item -> recentSnaps.reversed().forEach { item ->
append("${item.senderName}${item.conversationType} (${item.contentType})\n") append("${item.senderName}${item.conversationType} (${item.contentType})\n")
} }
@@ -473,114 +399,58 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
bigTextStyle.bigText(detailText) bigTextStyle.bigText(detailText)
builder.setStyle(bigTextStyle) builder.setStyle(bigTextStyle)
} }
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
} }
private fun formatDuration(m: Long): String { private fun createPendingIntent(action: String): PendingIntent {
val s = (m / 1000) % 60; val min = (m / 60000) % 60; val h = m / 3600000 val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName)
return when { h > 0 -> "${h}h ${min}m"; min > 0 -> "${min}m ${s}s"; else -> "${s}s" } return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
} }
private fun shutdownFeature() { private fun setupReceivers() {
cancelStatusNotification(); releaseWakeLock(); hasBeenActive.set(false); triggerLazySave() val actionReceiver = object : BroadcastReceiver() {
} override fun onReceive(ctx: Context?, intent: Intent?) {
when (intent?.action) {
private fun startWakeLockCooldown() { ACTION_PAUSE_RESUME -> { isPaused.set(!isPaused.get()); updateStatusNotification(force = true) }
wakeLockCooldownJob?.cancel() ACTION_CLEAR_QUEUE -> { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) }
wakeLockCooldownJob = context.coroutineScope.launch { ACTION_STOP_ENGINE -> shutdownFeature()
delay(30000) Intent.ACTION_BATTERY_CHANGED -> {
releaseWakeLock() val temp = intent.getIntExtra("temperature", 0) / 10f
} if (temp >= 40f && !isThermalThrottled) { isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis() }
} else if (isThermalThrottled && temp <= 36f && (System.currentTimeMillis() - lastThermalThrottleAt > 600000)) { isThermalThrottled = false }
}
private fun triggerLazySave() { }
needsSaving.set(true)
if (isSaving.compareAndSet(false, true)) {
context.coroutineScope.launch(Dispatchers.IO) {
while (needsSaving.get()) { needsSaving.set(false); saveToDiskInternal(); delay(300000) }
isSaving.set(false)
} }
} }
val filter = IntentFilter().apply { addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(ACTION_STOP_ENGINE); addAction(Intent.ACTION_BATTERY_CHANGED) }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter)
} }
private fun saveToDiskInternal() { private fun recordSpeedTimestamp() { synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 250) snapTimestamps.removeFirst() } }
prefs.edit {
putInt(PREF_TOTAL_OPENED, totalProcessed.get()) private fun shutdownFeature() {
putLong(PREF_SESSION_START, sessionStartTime.get()) engineActive.set(false)
synchronized(queuedSnaps) { putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps)) } snapChannel.close()
engineJob?.cancel()
releaseWakeLock()
cancelStatusNotification()
}
private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
fun getInterface(): AutoOpenInterface {
return object : AutoOpenInterface.Stub() {
override fun getProcessedCount(): Int = totalProcessed.get()
override fun getQueueItems(): List<String> = synchronized(queuedSnaps) { queuedSnaps.map { gson.toJson(it) } }
override fun reset() { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) }
} }
} }
private fun restorePersistence() { private fun getSenderDisplayName(userId: String): String = this@AutoOpenSnaps.context.database.getFriendInfo(userId)?.displayName ?: "Unknown"
val savedStartTime = prefs.getLong(PREF_SESSION_START, 0) private fun getConversationType(convId: String, senderId: String): String = if (this@AutoOpenSnaps.context.database.getDMOtherParticipant(convId) != null) "Friend DM" else this@AutoOpenSnaps.context.database.getFeedEntryByConversationId(convId)?.feedDisplayName ?: "Group Chat"
val now = System.currentTimeMillis() private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" }
if (now - savedStartTime > 3600000) {
prefs.edit().remove(PREF_SAVED_QUEUE).remove(PREF_TOTAL_OPENED).apply(); return
}
totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0))
sessionStartTime.set(savedStartTime)
val savedQueueJson = prefs.getString(PREF_SAVED_QUEUE, null)
if (!savedQueueJson.isNullOrBlank()) {
try {
val restored: List<SnapQueueItem> = gson.fromJson(savedQueueJson, object : TypeToken<List<SnapQueueItem>>() {}.type)
synchronized(queuedSnaps) { queuedSnaps.addAll(restored.filter { (now - it.timestamp) < 3600000 }) }
} catch (e: Exception) { prefs.edit().remove(PREF_SAVED_QUEUE).apply() }
}
}
private fun isInsideSleepWindow(): Boolean {
try {
val window = config.sleepWindow.get().split("-"); if (window.size != 2) return false
val start = window[0].split(":"); val end = window[1].split(":")
val now = Calendar.getInstance().apply { set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
val s = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, start[0].toInt()); set(Calendar.MINUTE, start[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
val e = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, end[0].toInt()); set(Calendar.MINUTE, end[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) }
return if (e.before(s)) now.after(s) || now.before(e) else now.after(s) && now.before(e)
} catch (e: Exception) { return false }
}
private fun isWifiConnected(): Boolean {
val cm = context.androidContext.getSystemService(ConnectivityManager::class.java) ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
cm.allNetworks.any { cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true }
} else {
@Suppress("DEPRECATION") cm.activeNetworkInfo?.type == ConnectivityManager.TYPE_WIFI
}
}
private fun isDeviceIdle(): Boolean = (context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode
private fun acquireWakeLock() {
wakeLockCooldownJob?.cancel()
if (wakeLock == null) {
val pm = context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { setReferenceCounted(false) }
wakeLock?.acquire(8 * 60 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null
}
private fun createPendingIntent(a: String): PendingIntent {
val i = Intent(a).apply { setPackage(context.androidContext.packageName) }
return PendingIntent.getBroadcast(context.androidContext, a.hashCode(), i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun createNotificationChannels() {
val c = NotificationChannel("auto_open_snaps", "Auto Open Snaps", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) }
notificationManager.createNotificationChannel(c)
}
private fun getSenderDisplayName(id: String): String = metadataCache.getOrPut(id) { context.database.getFriendInfo(id)?.let { it.displayName ?: it.mutableUsername } ?: "Unknown" }
private fun getConversationType(cid: String, sid: String): String = metadataCache.getOrPut("$cid:$sid") { if (context.database.getDMOtherParticipant(cid) != null) "Friend DM" else context.database.getFeedEntryByConversationId(cid)?.feedDisplayName ?: "Group Chat" }
private fun getSnapContentType(type: ContentType?): String = when (type) {
ContentType.SNAP -> "Photo/Video"
ContentType.EXTERNAL_MEDIA -> "Media"
else -> context.translation["auto_open_snaps.content_type_snap"] ?: "Snap"
}
} }
data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String, val timestamp: Long = System.currentTimeMillis())

View File

@@ -118,7 +118,7 @@ class Notifications : Feature("Notifications") {
val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) { val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) {
putExtra("conversation_id", conversationId) putExtra("conversation_id", conversationId)
putExtra("notification_id", notificationData.id) putExtra("notification_id", notificationData.id)
putExtra("client_message_id", message.messageDescriptor!!.messageId!!) putExtra("client_message_id", message.messageDescriptor!!.messageId!!.toLong())
} }
val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast(
@@ -160,7 +160,9 @@ class Notifications : Feature("Notifications") {
context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event -> context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event ->
val intent = event.intent ?: return@subscribe val intent = event.intent ?: return@subscribe
val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe
val clientMessageId = intent.getLongExtra("client_message_id", -1) val clientMessageId = intent.getLongExtra("client_message_id", -1L).takeIf { it != -1L }
?: intent.getStringExtra("client_message_id")?.toLongOrNull()
?: intent.getIntExtra("client_message_id", -1).toLong()
val notificationId = intent.getIntExtra("notification_id", -1) val notificationId = intent.getIntExtra("notification_id", -1)
val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder -> val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder ->
@@ -209,10 +211,15 @@ class Notifications : Feature("Notifications") {
}) })
} }
ACTION_DOWNLOAD -> { ACTION_DOWNLOAD -> {
runCatching { context.shortToast(context.translation.getCategory("download_processor")["download_started_toast"] ?: "Downloading...")
context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false) context.coroutineScope.launch(coroutineDispatcher) {
}.onFailure { runCatching {
context.longToast(it) if (clientMessageId <= 0) throw Exception("Message not found or expired in database.")
context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false)
}.onFailure {
val msg = if (it.message?.contains("not found", true) == true) "Message expired or already viewed." else it.message
context.longToast("Download failed: $msg")
}
} }
} }
ACTION_MARK_AS_READ -> { ACTION_MARK_AS_READ -> {

View File

@@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.times import androidx.compose.ui.unit.times
import me.eternal.purrfectsnap.common.scripting.JSModule
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.core.scripting package me.eternal.purrfectsnap.core.scripting
import me.eternal.purrfectsnap.common.scripting.JSModule
import me.eternal.purrfectsnap.bridge.scripting.AutoReloadListener import me.eternal.purrfectsnap.bridge.scripting.AutoReloadListener
import me.eternal.purrfectsnap.common.logger.AbstractLogger import me.eternal.purrfectsnap.common.logger.AbstractLogger
import me.eternal.purrfectsnap.common.scripting.ScriptRuntime import me.eternal.purrfectsnap.common.scripting.ScriptRuntime
@@ -7,6 +8,11 @@ import me.eternal.purrfectsnap.common.scripting.bindings.BindingSide
import me.eternal.purrfectsnap.core.ModContext import me.eternal.purrfectsnap.core.ModContext
import me.eternal.purrfectsnap.core.scripting.impl.* import me.eternal.purrfectsnap.core.scripting.impl.*
/**
* Core-side implementation of the [ScriptRuntime].
* Manages script lifecycle synchronized with the JNI bridge connection state
* to prevent race conditions during early-init hooks.
*/
class CoreScriptRuntime( class CoreScriptRuntime(
private val modContext: ModContext, private val modContext: ModContext,
logger: AbstractLogger, logger: AbstractLogger,
@@ -15,9 +21,18 @@ class CoreScriptRuntime(
androidContext = modContext.androidContext, androidContext = modContext.androidContext,
logger = logger logger = logger
) { ) {
// we assume that the bridge is reloaded the next time we connect to it // Indicates if the bridge has been reloaded at least once in this session
private var isBridgeReloaded = false private var isBridgeReloaded = false
/**
* Bridge connection status. Use [isBridgeConnected] to guard JNI-dependent operations.
*/
@Volatile
private var isBridgeConnected = false
/**
* Initializes the scripting environment and establishes bridge-aware lifecycle observers.
*/
fun init() { fun init() {
buildModuleObject = { module -> buildModuleObject = { module ->
putConst("currentSide", this, BindingSide.CORE.key) putConst("currentSide", this, BindingSide.CORE.key)
@@ -32,11 +47,14 @@ class CoreScriptRuntime(
modContext.bridgeClient.addOnConnectedCallback(initNow = true) { modContext.bridgeClient.addOnConnectedCallback(initNow = true) {
modContext.bridgeClient.getScriptingInterface()?.let { scriptingInterface -> modContext.bridgeClient.getScriptingInterface()?.let { scriptingInterface ->
logger.info("JNI Bridge established. Initializing scripts...")
scripting = scriptingInterface scripting = scriptingInterface
isBridgeConnected = true
if (!isBridgeReloaded) { if (!isBridgeReloaded) {
scriptingInterface.enabledScripts.forEach { path -> scriptingInterface.enabledScripts.forEach { path ->
runCatching { runCatching {
logger.verbose("Loading script: $path")
load(path, scriptingInterface.getScriptContent(path)) load(path, scriptingInterface.getScriptContent(path))
}.onFailure { }.onFailure {
logger.error("Failed to load script $path", it) logger.error("Failed to load script $path", it)
@@ -46,6 +64,7 @@ class CoreScriptRuntime(
scriptingInterface.registerAutoReloadListener(object : AutoReloadListener.Stub() { scriptingInterface.registerAutoReloadListener(object : AutoReloadListener.Stub() {
override fun restartApp() { override fun restartApp() {
logger.info("Script change detected. Soft-restarting app...")
modContext.softRestartApp() modContext.softRestartApp()
} }
}) })
@@ -57,7 +76,18 @@ class CoreScriptRuntime(
if (!isBridgeReloaded) { if (!isBridgeReloaded) {
isBridgeReloaded = true isBridgeReloaded = true
} }
} ?: run {
isBridgeConnected = false
logger.error("JNI Bridge callback triggered but interface is null.")
} }
} }
} }
/**
* Safely iterates over loaded modules only when the JNI bridge is confirmed connected.
*/
override fun eachModule(f: JSModule.() -> Unit) {
if (!isBridgeConnected) return
super.eachModule(f)
}
} }

View File

@@ -43,6 +43,7 @@ import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.FriendLinkType import me.eternal.purrfectsnap.common.data.FriendLinkType
import me.eternal.purrfectsnap.common.database.impl.ConversationMessage import me.eternal.purrfectsnap.common.database.impl.ConversationMessage
import me.eternal.purrfectsnap.common.database.impl.FriendInfo import me.eternal.purrfectsnap.common.database.impl.FriendInfo
import me.eternal.purrfectsnap.common.scripting.JSModule
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface

View File

@@ -30,6 +30,38 @@ class ParamMap(obj: Any?) : AbstractWrapper(obj) {
return concurrentHashMap.keys.any { k: Any -> k.toString() == key } return concurrentHashMap.keys.any { k: Any -> k.toString() == key }
} }
fun getStoryIdentity(): String? {
return this["STORY_ID"]?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
?: this["TOPIC_SNAP_CREATOR_USER_ID"]?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
?: this["STORY_SNAP_ID"]?.toString()
?.substringBefore("_")
?.takeIf { it.isNotBlank() && it != "null" }
?: this["PLAYLIST_V2_GROUP"]?.toString()
?.substringAfter("storyUserId=", "")
?.substringBefore(",")
?.takeIf { it.isNotBlank() && it != "null" }
?: this["PLAYABLE_STORY_SNAP_RECORD"]?.toString()
?.substringAfter("storyUserId=", "")
?.substringBefore(",")
?.takeIf { it.isNotBlank() && it != "null" }
}
fun getStorySnapIndex(): Int? {
return (this["STORY_SNAP_INDEX"] as? Int)
?: (this["snap_index_in_story"]?.toString()?.toIntOrNull())
?: (this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull())
?: (this["REPLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("snapIndex=", "")?.substringBefore(",")?.toIntOrNull())
}
fun getStorySnapTotal(): Int {
return (this["STORY_SNAP_TOTAL"] as? Int)
?: (this["snap_story_length"]?.toString()?.toIntOrNull())
?: (this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull())
?: 0
}
override fun toString(): String { override fun toString(): String {
return concurrentHashMap.toString() return concurrentHashMap.toString()
} }

36
native/rust/Cargo.lock generated
View File

@@ -107,9 +107,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.9.0" version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "bzip2" name = "bzip2"
@@ -563,9 +563,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
@@ -821,18 +821,28 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.217" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.217" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -948,22 +958,22 @@ dependencies = [
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.44" version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [ dependencies = [
"deranged", "deranged",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde", "serde_core",
"time-core", "time-core",
] ]
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.6" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]] [[package]]
name = "typenum" name = "typenum"

View File

@@ -8,12 +8,17 @@ pub fn native_config() -> NativeConfig {
NATIVE_CONFIG.lock().unwrap().as_ref().expect("NativeConfig not loaded").clone() NATIVE_CONFIG.lock().unwrap().as_ref().expect("NativeConfig not loaded").clone()
} }
/// Native configuration structure mirrored from 'NativeConfig.kt'.
///
/// CRITICAL: Fields must maintain 1:1 parity with the Kotlin implementation.
/// Mismatches in field names, types, or order will result in a JNI SIGABRT.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct NativeConfig { pub(crate) struct NativeConfig {
pub disable_bitmoji: bool, pub disable_bitmoji: bool,
pub disable_metrics: bool, pub disable_metrics: bool,
pub valdi_hooks: bool, pub valdi_hooks: bool,
pub custom_emoji_font_path: Option<String>, pub custom_emoji_font_path: Option<String>,
pub debug_font_redirect: bool,
} }
impl NativeConfig { impl NativeConfig {
@@ -41,6 +46,7 @@ impl NativeConfig {
disable_metrics: get_boolean!("disableMetrics"), disable_metrics: get_boolean!("disableMetrics"),
valdi_hooks: get_boolean!("valdiHooks"), valdi_hooks: get_boolean!("valdiHooks"),
custom_emoji_font_path: get_string!("customEmojiFontPath"), custom_emoji_font_path: get_string!("customEmojiFontPath"),
debug_font_redirect: get_boolean!("debugFontRedirect"),
}) })
} }
} }

View File

@@ -1,7 +1,5 @@
use std::{cell::Cell, ffi::{CStr, CString}}; use std::{cell::Cell, ffi::{CStr, CString}};
use nix::libc::{self, c_uint}; use nix::libc::{self, c_uint};
use crate::{config, def_hook, dobby_hook_sym}; use crate::{config, def_hook, dobby_hook_sym};
thread_local! { thread_local! {
@@ -23,6 +21,8 @@ fn should_redirect_font(pathname: &str) -> bool {
file_name.contains("emoji") file_name.contains("emoji")
|| file_name == "noto_color_emoji.ttf" || file_name == "noto_color_emoji.ttf"
|| file_name == "samsungcoloremoji.ttf" || file_name == "samsungcoloremoji.ttf"
|| file_name == "coloremojifont.ttf"
|| file_name == "coloros_color_emoji.ttf"
) )
} }
@@ -33,7 +33,7 @@ fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option<i32> {
Ok(c_font_path) => { Ok(c_font_path) => {
let fd = FONT_REDIRECT_IN_PROGRESS.with(|guard| { let fd = FONT_REDIRECT_IN_PROGRESS.with(|guard| {
let was_active = guard.replace(true); let was_active = guard.replace(true);
let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode) }; let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const libc::c_char, flags, mode) };
guard.set(was_active); guard.set(was_active);
fd fd
}); });
@@ -41,6 +41,9 @@ fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option<i32> {
debug!("redirected emoji font open to {}", font_path); debug!("redirected emoji font open to {}", font_path);
Some(fd) Some(fd)
} else { } else {
if config::native_config().debug_font_redirect {
panic!("Failed to open custom emoji font: {}", font_path);
}
debug!("failed to open custom emoji font path (fd={}): {}", fd, font_path); debug!("failed to open custom emoji font path (fd={}): {}", fd, font_path);
None None
} }
@@ -61,7 +64,7 @@ def_hook!(
} }
if !path.is_null() { if !path.is_null() {
if let Ok(pathname) = CStr::from_ptr(path).to_str() { if let Ok(pathname) = unsafe { CStr::from_ptr(path as *const libc::c_char) }.to_str() {
if should_redirect_font(pathname) { if should_redirect_font(pathname) {
if let Some(fd) = open_custom_font_fd(flags, mode) { if let Some(fd) = open_custom_font_fd(flags, mode) {
return fd; return fd;
@@ -74,10 +77,33 @@ def_hook!(
} }
); );
def_hook!(
openat_hook,
i32,
|dirfd: i32, path: *const u8, flags: i32, mode: c_uint| {
if FONT_REDIRECT_IN_PROGRESS.with(|guard| guard.get()) {
return openat_hook_original.unwrap()(dirfd, path, flags, mode);
}
if !path.is_null() {
if let Ok(pathname) = unsafe { CStr::from_ptr(path as *const libc::c_char) }.to_str() {
if should_redirect_font(pathname) {
if let Some(fd) = open_custom_font_fd(flags, mode) {
return fd;
}
}
}
}
openat_hook_original.unwrap()(dirfd, path, flags, mode)
}
);
pub fn init() { pub fn init() {
if config::native_config().custom_emoji_font_path.is_none() { if config::native_config().custom_emoji_font_path.is_none() {
return; return;
} }
dobby_hook_sym!("libc.so", "open", open_hook); dobby_hook_sym!("libc.so", "open", open_hook);
dobby_hook_sym!("libc.so", "openat", openat_hook);
} }

View File

@@ -42,6 +42,9 @@ pub struct ValdiModule {
impl ValdiModule { impl ValdiModule {
pub fn parse(buffer: Vec<u8>) -> Result<ValdiModule, Error> { pub fn parse(buffer: Vec<u8>) -> Result<ValdiModule, Error> {
if buffer.len() < 8 {
return Err(Error::new(std::io::ErrorKind::InvalidData, "Buffer too small"));
}
let mut offset = 0; let mut offset = 0;
let magic = u32::from_be_bytes([buffer[offset], buffer[offset + 1], buffer[offset + 2], buffer[offset + 3]]); let magic = u32::from_be_bytes([buffer[offset], buffer[offset + 1], buffer[offset + 2], buffer[offset + 3]]);
@@ -62,14 +65,13 @@ impl ValdiModule {
} }
fn read_u32(buffer: &Vec<u8>, offset: &mut usize) -> Result<(u32, bool), Error> { fn read_u32(buffer: &Vec<u8>, offset: &mut usize) -> Result<(u32, bool), Error> {
let b1 = buffer[*offset] as u32; let bytes = [buffer[*offset], buffer[*offset + 1], buffer[*offset + 2], buffer[*offset + 3]];
let b2 = buffer[*offset + 1] as u32; let value = u32::from_be_bytes(bytes);
let b3 = buffer[*offset + 2] as u32; let has_padding = (value & 0x80000000) != 0;
let b4 = (buffer[*offset + 3] & 0x7f) as u32; let tag_size = value & 0x7FFFFFFF;
let has_padding = (buffer[*offset + 3] & 0x80) != 0;
*offset += 4; *offset += 4;
Ok((b1 | (b2 << 8) | (b3 << 16) | (b4 << 24), has_padding)) Ok((tag_size, has_padding))
} }
let (tag_size, has_padding) = read_u32(&buffer, &mut offset)?; let (tag_size, has_padding) = read_u32(&buffer, &mut offset)?;
@@ -98,10 +100,8 @@ impl ValdiModule {
let mut tag_buffer = Vec::new(); let mut tag_buffer = Vec::new();
fn write_u32(buffer: &mut Vec<u8>, value: u32, has_padding: bool) { fn write_u32(buffer: &mut Vec<u8>, value: u32, has_padding: bool) {
buffer.push(value as u8); let encoded_value = (value & 0x7FFFFFFF) | if has_padding { 0x80000000 } else { 0 };
buffer.push(((value >> 8) & 0xff) as u8); buffer.extend_from_slice(&encoded_value.to_be_bytes());
buffer.push(((value >> 16) & 0xff) as u8);
buffer.push(((value >> 24) & 0x7f) as u8 | if has_padding { 0x80 } else { 0x00 });
} }
fn write_tag(buffer: &mut Vec<u8>, tag: ModuleTag) { fn write_tag(buffer: &mut Vec<u8>, tag: ModuleTag) {
@@ -125,7 +125,7 @@ impl ValdiModule {
let mut buffer = Vec::new(); let mut buffer = Vec::new();
buffer.extend_from_slice(&[0x33, 0xc6, 0, 1]); buffer.extend_from_slice(&[0x33, 0xc6, 0, 1]);
buffer.extend_from_slice(&(tag_buffer.len() as u32).to_le_bytes()); buffer.extend_from_slice(&(tag_buffer.len() as u32).to_be_bytes());
buffer.extend(tag_buffer); buffer.extend(tag_buffer);
buffer buffer

View File

@@ -16,7 +16,10 @@ def_hook!(
if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) { if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) {
return buffer.len() as i32; return buffer.len() as i32;
} }
aasset_get_length_original.unwrap()(arg0) if let Some(original) = aasset_get_length_original {
return original(arg0);
}
0
} }
); );
@@ -27,7 +30,10 @@ def_hook!(
if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) { if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) {
return buffer.as_ptr() as *const c_void; return buffer.as_ptr() as *const c_void;
} }
aasset_get_buffer_original.unwrap()(arg0) if let Some(original) = aasset_get_buffer_original {
return original(arg0);
}
std::ptr::null()
} }
); );
@@ -35,53 +41,89 @@ def_hook!(
aasset_manager_open, aasset_manager_open,
*mut c_void, *mut c_void,
|arg0: *mut c_void, arg1: *const u8, arg2: i32| { |arg0: *mut c_void, arg1: *const u8, arg2: i32| {
let handle = aasset_manager_open_original.unwrap()(arg0, arg1, arg2); let original_fn = match aasset_manager_open_original {
Some(f) => f,
None => return std::ptr::null_mut(),
};
let path = std::ffi::CStr::from_ptr(arg1).to_str().unwrap_or_default(); let handle = original_fn(arg0, arg1, arg2);
if !handle.is_null() && path.starts_with("bridge_observables") { if handle.is_null() {
let asset_buffer = aasset_get_buffer_original.unwrap()(handle); return handle;
let asset_length = aasset_get_length_original.unwrap()(handle); }
debug!("asset buffer: {:p}, length: {}", asset_buffer, asset_length);
let loader_data = LOADER_DATA.lock().unwrap().clone().expect("No loader data"); let path_cstr = unsafe { std::ffi::CStr::from_ptr(arg1 as *const std::os::raw::c_char) };
let path = path_cstr.to_str().unwrap_or_default();
// Only target compressed Valdi bridge observables
if path.ends_with(".zst") && path.contains("bridge_observables") {
let get_buffer_fn = match aasset_get_buffer_original {
Some(f) => f,
None => return handle,
};
let get_length_fn = match aasset_get_length_original {
Some(f) => f,
None => return handle,
};
let archive_buffer: Vec<u8> = std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec(); let asset_buffer = get_buffer_fn(handle);
let decompressed = zstd::stream::decode_all(&archive_buffer[..]).expect("Failed to decompress valdi archive"); let asset_length = get_length_fn(handle);
let mut valdi_module = ValdiModule::parse(decompressed).expect("Failed to parse valdi module");
if asset_buffer.is_null() || asset_length <= 0 {
let mut tags = valdi_module.get_tags(); return handle;
let mut new_tags = Vec::new();
for (tag1, _) in tags.iter_mut() {
let name = tag1.to_string().unwrap_or_default();
if !name.ends_with("src/utils/converter.js") {
continue;
}
let old_file_name = name.split_once(".").unwrap().0.to_owned() + rand::random::<u32>().to_string().as_str();
tag1.set_buffer((old_file_name.to_owned() + ".js").as_bytes().to_vec());
let original_module_path = path.split_once(".").unwrap().0.to_owned() + "/" + &old_file_name;
let hooked_module = format!("{};module.exports = require(\"{}\");", loader_data, original_module_path);
new_tags.push(
(
ModuleTag::new(true, name.as_bytes().to_vec()),
ModuleTag::new(true, hooked_module.as_bytes().to_vec())
)
);
debug!("Valdi loader injected in {}", name);
break;
} }
tags.extend(new_tags); let loader_data = match LOADER_DATA.lock().unwrap().clone() {
valdi_module.set_tags(tags); Some(data) => data,
None => {
warn!("Valdi loader data not yet initialized for {}", path);
return handle;
}
};
let compressed = valdi_module.to_bytes(); let archive_buffer: Vec<u8> = unsafe {
let compressed = zstd::stream::encode_all(&compressed[..], 3).expect("Failed to compress"); std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec()
};
let decompressed = match zstd::stream::decode_all(&archive_buffer[..]) {
Ok(data) => data,
Err(e) => {
error!("Failed to decompress Valdi archive {}: {}", path, e);
return handle;
}
};
AASSET_MAP.lock().unwrap().insert(handle as usize, compressed); let valdi_module = match ValdiModule::parse(decompressed) {
Ok(module) => module,
Err(e) => {
error!("Failed to parse Valdi module {}: {}", path, e);
return handle;
}
};
let mut tags = valdi_module.get_tags();
let mut found = false;
for (tag1, tag2) in tags.iter_mut() {
let name = tag1.to_string().unwrap_or_default();
if name.ends_with("src/utils/converter.js") {
let mut hooked_content = loader_data.as_bytes().to_vec();
hooked_content.extend_from_slice(tag2.get_buffer());
*tag2 = ModuleTag::new(true, hooked_content);
found = true;
debug!("Valdi loader prepended to {}", name);
break;
}
}
if found {
let compressed = valdi_module.to_bytes();
match zstd::stream::encode_all(&compressed[..], 3) {
Ok(compressed_data) => {
AASSET_MAP.lock().unwrap().insert(handle as usize, compressed_data);
},
Err(e) => error!("Failed to re-compress Valdi module: {}", e),
}
}
} }
handle handle
} }
@@ -89,16 +131,19 @@ def_hook!(
def_hook!( def_hook!(
aasset_close, aasset_close,
c_void, (),
|handle: *mut c_void| { |handle: *mut c_void| {
AASSET_MAP.lock().unwrap().remove(&(handle as usize)); AASSET_MAP.lock().unwrap().remove(&(handle as usize));
aasset_close_original.unwrap()(handle) if let Some(original) = aasset_close_original {
original(handle);
}
} }
); );
pub fn set_valdi_loader(mut env: JNIEnv, _: *mut c_void, code: JString) { pub fn set_valdi_loader(mut env: JNIEnv, _: *mut c_void, code: JString) {
let new_code = get_jni_string(&mut env, code).expect("Failed to get loader code"); if let Ok(new_code) = get_jni_string(&mut env, code) {
LOADER_DATA.lock().unwrap().replace(new_code); LOADER_DATA.lock().unwrap().replace(new_code);
}
} }
pub fn init() { pub fn init() {

View File

@@ -1,5 +1,12 @@
package me.eternal.purrfectsnap.nativelib package me.eternal.purrfectsnap.nativelib
/**
* Configuration schema for the native layer.
*
* CRITICAL: This class MUST maintain 1:1 field parity with 'native/rust/src/config.rs'.
* Any modification to field names, types, or order without a corresponding change
* in the Rust implementation will cause a JNI SIGABRT (crash on launch).
*/
data class NativeConfig( data class NativeConfig(
@JvmField @JvmField
val disableBitmoji: Boolean = false, val disableBitmoji: Boolean = false,
@@ -9,4 +16,6 @@ data class NativeConfig(
val valdiHooks: Boolean = false, val valdiHooks: Boolean = false,
@JvmField @JvmField
val customEmojiFontPath: String? = null, val customEmojiFontPath: String? = null,
@JvmField
val debugFontRedirect: Boolean = false,
) )