Auto Open Engine refactor and Media Downloader stabilization.

This commit is contained in:
DarkKnight2122
2026-04-13 00:07:06 +05:30
parent 0db1c5036e
commit 279e847d00
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 overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value
val renamedMedia = renameFromFileType(media, FileType.fromFile(media))
val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia))
val mediaFileType = FileType.fromFile(media)
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")
runCatching {
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> {
val stringBuilder = StringBuilder()
arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
@@ -127,7 +129,7 @@ class FFMpegProcessor(
Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE
else -> return@logFunction
}, log.message)
}, { onStatistics(it) }, Executors.newSingleThreadExecutor())
}, { onStatistics(it) }, sharedExecutor)
}
suspend fun execute(args: Request) {
@@ -162,7 +164,7 @@ class FFMpegProcessor(
}
Action.MERGE_OVERLAY -> {
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 -> {
if (ffmpegOptions.customAudioCodec.isEmpty()) {
@@ -187,45 +189,47 @@ class FFMpegProcessor(
}.getOrNull()?.let { file to it }
}
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
}?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
try {
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
}?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder()
var containsNoSound = false
val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder()
var containsNoSound = false
filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}]")
filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
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 -> {
outputArguments.clear()

View File

@@ -86,7 +86,8 @@ class AnnouncementCheckWorker(
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
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)
.setContentText(text)
.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.MoreVert
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.Info
import androidx.compose.material.icons.outlined.Report
@@ -170,6 +171,7 @@ class HomeLogs : Routes.Route() {
internal fun LogsFloatingBar(
isRefreshing: Boolean,
onRefresh: () -> Unit,
onFilter: () -> Unit,
onExport: () -> Unit,
onClear: () -> Unit
) {
@@ -222,6 +224,20 @@ class HomeLogs : Routes.Route() {
verticalAlignment = Alignment.CenterVertically,
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) {
Icon(
imageVector = Icons.Filled.Refresh,
@@ -457,7 +473,31 @@ class HomeLogs : Routes.Route() {
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 {
val category = getCategoryForLog(line)
if (category != null && enabledCategories[category] == false) return true
val message = line.message.lowercase()
val tag = line.tag.lowercase()
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavBackStackEntry
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
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.Motion
import kotlinx.coroutines.launch
@@ -37,6 +41,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
var isRefreshing by remember { mutableStateOf(false) }
var showFilterDialog by remember { mutableStateOf(false) }
fun refreshLogs() {
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) {
if (externalRefreshTick.value > 0) {
refreshLogs()
@@ -132,6 +198,9 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
color = Color.White
)
}
IconButton(onClick = { showFilterDialog = true }) {
Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary)
}
IconButton(onClick = { refreshLogs() }) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
}

View File

@@ -1121,6 +1121,8 @@ object LegacyTheme : ThemeContract {
val visibleLogs = remember { mutableStateListOf<LogLine>() }
val mainExecutor = remember { context.androidContext.mainExecutor }
var isRefreshing by remember { mutableStateOf(false) }
var showFilterDialog by remember { mutableStateOf(false) }
fun refreshLogs() {
coroutineScope.launch {
val readerResult = withContext(Dispatchers.IO) {
@@ -1154,6 +1156,71 @@ object LegacyTheme : ThemeContract {
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) {
if (externalRefreshTick.intValue > 0) {
isRefreshing = true
@@ -1181,6 +1248,7 @@ object LegacyTheme : ThemeContract {
isRefreshing = true
refreshLogs()
},
onFilter = { showFilterDialog = true },
onExport = { exportLogs() },
onClear = { clearLogsAndReload() }
)

View File

@@ -256,6 +256,15 @@
"home_logs": {
"no_logs_hint": "No logs available",
"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",
"export_logs_button": "Export Logs",
"saving_logs_toast": "Saving logs, this may take a while ...",
@@ -1676,6 +1685,14 @@
"name": "Allow Running in Background",
"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": {
"name": "Min Delay (ms)",
"description": "Minimum delay in milliseconds before opening a snap"
@@ -2165,6 +2182,10 @@
"name": "Disable 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": {
"name": "Custom Emoji Font",
"description": "Allows you to use a custom emoji font. Only works with .ttf fonts"
@@ -3725,6 +3746,7 @@
"snap_item": "Snap {index} of {total}"
},
"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."
},
"streaks_reminder": {
@@ -4385,8 +4407,7 @@
"deepseek": "DeepSeek",
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
,
},
"tasks_no_tasks": "No tasks",
"tasks_no_active_tasks": "No active tasks",
"tasks_no_scheduled_tasks": "No scheduled snaps",
@@ -4395,8 +4416,8 @@
"tasks_clear_button_description": "Clear tasks",
"tasks_delete_button": "Delete",
"tasks_merge_button": "Merge",
"tasks_summary_active": "{active} active · {recent} recent",
"tasks_summary_idle": "Idle · {recent} recent",
"tasks_summary_active": "{active} active \u2022 {recent} recent",
"tasks_summary_idle": "Idle \u2022 {recent} recent",
"tasks_running_count": "{count} running",
"tasks_tagline": "Monitor and manage background actions",
"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 preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") {
addFlags(ConfigFlag.NO_TRANSLATE)
}
val constantRateFactor = integer("constant_rate_factor", 30)
val videoBitrate = integer("video_bitrate", 5000)
}.apply { set("veryfast") }
val constantRateFactor = integer("constant_rate_factor", 22)
val videoBitrate = integer("video_bitrate", 8000)
val audioBitrate = integer("audio_bitrate", 128)
val customVideoCodec = string("custom_video_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() {
val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() }
val disableBitmoji = boolean("disable_bitmoji")
val debugFontRedirect = boolean("debug_font_redirect") { addFlags(ConfigFlag.HIDDEN) }
val customEmojiFont = string("custom_emoji_font") {
requireRestart()
addFlags(ConfigFlag.USER_IMPORT)

View File

@@ -166,12 +166,18 @@ class MessagingTweaks : ConfigContainer() {
val maxDelayMs = integer("max_delay_ms", defaultValue = 100) {
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 }
}
val retryAttempts = integer("retry_attempts", defaultValue = 5) {
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) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
@@ -18,23 +17,22 @@ import androidx.core.content.edit
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
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.common.BuildConfig
import me.eternal.purrfectsnap.common.config.PropertyValue
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessageState
import me.eternal.purrfectsnap.common.data.MessageUpdate
import me.eternal.purrfectsnap.common.data.MessagingRuleType
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.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.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import java.util.*
import java.util.Objects
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
@@ -42,400 +40,326 @@ import java.util.concurrent.atomic.AtomicLong
import kotlin.coroutines.resume
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) {
companion object {
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_PAUSE_RESUME"
const val ACTION_CLEAR_QUEUE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_CLEAR_QUEUE"
const val ACTION_STOP_ENGINE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_STOP_ENGINE"
private const val STATUS_NOTIFICATION_ID = 54321
private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN"
private const val PREF_TOTAL_OPENED = "auto_open_total_opened"
private const val PREF_SESSION_START = "auto_open_session_start"
private const val PREF_SAVED_QUEUE = "auto_open_saved_queue"
private const val LAZY_SAVE_INTERVAL_MS = 600_000L
}
private val gson = Gson()
private val isPaused = AtomicBoolean(false)
private val totalProcessed = AtomicInteger(0)
private val sessionProcessed = AtomicInteger(0)
private val engineActive = AtomicBoolean(true)
private val totalProcessed = AtomicInteger(0)
private val sessionProcessed = AtomicInteger(0)
private val sessionStartTime = AtomicLong(System.currentTimeMillis())
private val totalPausedDuration = AtomicLong(0)
private var lastPausedAt = AtomicLong(0)
private val averageProcessingTime = AtomicLong(800)
private val hasBeenActive = AtomicBoolean(false)
private val isScreenOn = AtomicBoolean(true)
private val lastSnapProcessedAt = AtomicLong(0)
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 openedSnaps = ConcurrentHashMap.newKeySet<Long>()
private val queuedSnaps = mutableListOf<SnapQueueItem>()
private val deadLetterQueue = mutableListOf<SnapQueueItem>()
private val autoOpenConfig by lazy { this@AutoOpenSnaps.context.config.messaging.autoOpenSnaps }
private val notificationManager by lazy { this@AutoOpenSnaps.context.androidContext.getSystemService(NotificationManager::class.java) }
private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) }
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 currentSpeedText = "Full Speed"
private var isCurrentlyWaiting = false
private var wakeLock: PowerManager.WakeLock? = null
private var wakeLockCooldownJob: Job? = null
private var lastQueueActivity = System.currentTimeMillis()
private val lastNotificationUpdate = AtomicLong(0)
private var lastNotificationUpdate = 0L
private val notificationUpdateDelay = 1000L
private val pendingNotificationUpdate = AtomicBoolean(false)
private val snapTimestamps = LinkedList<Long>()
private var lastConversationId: String? = null
private val isSaving = AtomicBoolean(false)
private val needsSaving = AtomicBoolean(false)
private var isThermalThrottled = false
private var lastThermalThrottleAt = 0L
private fun cancelStatusNotification() {
runCatching { notificationManager.cancel(STATUS_NOTIFICATION_ID) }
}
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 logInfo(msg: String) = this@AutoOpenSnaps.context.log.info("[AutoOpenEngine] $msg")
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")
private fun getSnapsPerSecond(): Double {
val now = System.currentTimeMillis(); val window = 5000L
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) {
val currentTime = System.currentTimeMillis(); val lastUpdate = lastNotificationUpdate.get()
val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
if (!isScreenOn.get() && !force) return
if (!force && (currentTime - lastUpdate) < notificationUpdateDelay) {
val now = System.currentTimeMillis()
if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) {
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
}
lastNotificationUpdate.set(currentTime); updateStatusNotificationInternal()
updateStatusNotificationInternal()
}
private var lastNotificationStateHash: Int = 0
private fun updateStatusNotificationInternal() {
if (!engineActive.get()) return
val processed = sessionProcessed.get()
val total = totalProcessed.get()
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 isCompact = config.compactNotification.get() == true
val speed = if (isWorking) getSnapsPerSecond() else 0.0
lastNotificationUpdate = System.currentTimeMillis(); pendingNotificationUpdate.set(false)
val sessionTotal = processed + remaining
val speed = getSnapsPerSecond()
val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0
val eta = if (isWorking && !isCurrentlyWaiting && !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 eta = if (isWorking && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..."
val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status")
.setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY)
// 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")
val isCompact = (autoOpenConfig.compactNotification as PropertyValue<Boolean>).get()
if (isWorking) {
builder.setContentText("Opened: $processed │ Queue: $remaining")
builder.setSubText("$progressPercent% • Ends in: ${eta ?: "..."}")
builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)")
builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta")
builder.setProgress(sessionTotal, processed, false)
} else {
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, "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) {
val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) }
@@ -452,16 +377,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
val detailText = buildString {
append("QUEUE STATISTICS\n")
append("├─ Opened: $processed snaps\n")
append("├─ Queue: $remaining snaps\n")
append("├─ Total Opened: $total snaps\n")
val speedNotion = if (remaining > 0) currentSpeedText else "Idle"
val speedValue = if (remaining > 0) "${String.format("%.1f", speed)}/s" else "0.0/s"
append("└─ Speed: $speedNotion ($speedValue)\n\n")
append("├─ Queue: $remaining snaps • Ends in: $eta\n")
if ((autoOpenConfig.showLifetimeStats as PropertyValue<Boolean>).get()) {
append("├─ Total Opened: $total snaps\n")
}
val speedNotion = if (isWorking) currentSpeedText else "Idle"
val speedValue = "${String.format(Locale.US, "%.1f", speed)}/s"
append("└─ Speed: $speedNotion ($speedValue)\n")
if (config.showQueuePreview.get()) {
append("\n\nQUEUE PREVIEW\n")
if (isWorking) {
if ((autoOpenConfig.showQueuePreview as PropertyValue<Boolean>).get()) {
append("\nQUEUE PREVIEW\n")
if (isWorking && remaining > 0) {
recentSnaps.reversed().forEach { item ->
append("${item.senderName}${item.conversationType} (${item.contentType})\n")
}
@@ -473,114 +399,58 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
bigTextStyle.bigText(detailText)
builder.setStyle(bigTextStyle)
}
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
}
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" }
private fun createPendingIntent(action: String): PendingIntent {
val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName)
return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
private fun shutdownFeature() {
cancelStatusNotification(); releaseWakeLock(); hasBeenActive.set(false); triggerLazySave()
}
private fun startWakeLockCooldown() {
wakeLockCooldownJob?.cancel()
wakeLockCooldownJob = context.coroutineScope.launch {
delay(30000)
releaseWakeLock()
}
}
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)
private fun setupReceivers() {
val actionReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
when (intent?.action) {
ACTION_PAUSE_RESUME -> { isPaused.set(!isPaused.get()); updateStatusNotification(force = true) }
ACTION_CLEAR_QUEUE -> { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) }
ACTION_STOP_ENGINE -> shutdownFeature()
Intent.ACTION_BATTERY_CHANGED -> {
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 }
}
}
}
}
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() {
prefs.edit {
putInt(PREF_TOTAL_OPENED, totalProcessed.get())
putLong(PREF_SESSION_START, sessionStartTime.get())
synchronized(queuedSnaps) { putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps)) }
private fun recordSpeedTimestamp() { synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 250) snapTimestamps.removeFirst() } }
private fun shutdownFeature() {
engineActive.set(false)
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() {
val savedStartTime = prefs.getLong(PREF_SESSION_START, 0)
val now = System.currentTimeMillis()
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"
}
private fun getSenderDisplayName(userId: String): String = this@AutoOpenSnaps.context.database.getFriendInfo(userId)?.displayName ?: "Unknown"
private fun getConversationType(convId: String, senderId: String): String = if (this@AutoOpenSnaps.context.database.getDMOtherParticipant(convId) != null) "Friend DM" else this@AutoOpenSnaps.context.database.getFeedEntryByConversationId(convId)?.feedDisplayName ?: "Group Chat"
private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" }
}
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) {
putExtra("conversation_id", conversationId)
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(
@@ -160,7 +160,9 @@ class Notifications : Feature("Notifications") {
context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event ->
val intent = event.intent ?: 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 updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder ->
@@ -209,10 +211,15 @@ class Notifications : Feature("Notifications") {
})
}
ACTION_DOWNLOAD -> {
runCatching {
context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false)
}.onFailure {
context.longToast(it)
context.shortToast(context.translation.getCategory("download_processor")["download_started_toast"] ?: "Downloading...")
context.coroutineScope.launch(coroutineDispatcher) {
runCatching {
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 -> {

View File

@@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.InterfaceManager
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface

View File

@@ -1,5 +1,6 @@
package me.eternal.purrfectsnap.core.scripting
import me.eternal.purrfectsnap.common.scripting.JSModule
import me.eternal.purrfectsnap.bridge.scripting.AutoReloadListener
import me.eternal.purrfectsnap.common.logger.AbstractLogger
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.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(
private val modContext: ModContext,
logger: AbstractLogger,
@@ -15,9 +21,18 @@ class CoreScriptRuntime(
androidContext = modContext.androidContext,
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
/**
* 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() {
buildModuleObject = { module ->
putConst("currentSide", this, BindingSide.CORE.key)
@@ -32,11 +47,14 @@ class CoreScriptRuntime(
modContext.bridgeClient.addOnConnectedCallback(initNow = true) {
modContext.bridgeClient.getScriptingInterface()?.let { scriptingInterface ->
logger.info("JNI Bridge established. Initializing scripts...")
scripting = scriptingInterface
isBridgeConnected = true
if (!isBridgeReloaded) {
scriptingInterface.enabledScripts.forEach { path ->
runCatching {
logger.verbose("Loading script: $path")
load(path, scriptingInterface.getScriptContent(path))
}.onFailure {
logger.error("Failed to load script $path", it)
@@ -46,6 +64,7 @@ class CoreScriptRuntime(
scriptingInterface.registerAutoReloadListener(object : AutoReloadListener.Stub() {
override fun restartApp() {
logger.info("Script change detected. Soft-restarting app...")
modContext.softRestartApp()
}
})
@@ -57,7 +76,18 @@ class CoreScriptRuntime(
if (!isBridgeReloaded) {
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.database.impl.ConversationMessage
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.InterfaceManager
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 }
}
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 {
return concurrentHashMap.toString()
}

36
native/rust/Cargo.lock generated
View File

@@ -107,9 +107,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.9.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzip2"
@@ -563,9 +563,9 @@ dependencies = [
[[package]]
name = "num-conv"
version = "0.1.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-traits"
@@ -821,18 +821,28 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.217"
version = "1.0.228"
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 = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.217"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -948,22 +958,22 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.44"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde",
"serde_core",
"time-core",
]
[[package]]
name = "time-core"
version = "0.1.6"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
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 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)]
pub(crate) struct NativeConfig {
pub disable_bitmoji: bool,
pub disable_metrics: bool,
pub valdi_hooks: bool,
pub custom_emoji_font_path: Option<String>,
pub debug_font_redirect: bool,
}
impl NativeConfig {
@@ -41,6 +46,7 @@ impl NativeConfig {
disable_metrics: get_boolean!("disableMetrics"),
valdi_hooks: get_boolean!("valdiHooks"),
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 nix::libc::{self, c_uint};
use crate::{config, def_hook, dobby_hook_sym};
thread_local! {
@@ -23,6 +21,8 @@ fn should_redirect_font(pathname: &str) -> bool {
file_name.contains("emoji")
|| file_name == "noto_color_emoji.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) => {
let fd = FONT_REDIRECT_IN_PROGRESS.with(|guard| {
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);
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);
Some(fd)
} 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);
None
}
@@ -61,7 +64,7 @@ def_hook!(
}
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 let Some(fd) = open_custom_font_fd(flags, mode) {
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() {
if config::native_config().custom_emoji_font_path.is_none() {
return;
}
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 {
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 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> {
let b1 = buffer[*offset] as u32;
let b2 = buffer[*offset + 1] as u32;
let b3 = buffer[*offset + 2] as u32;
let b4 = (buffer[*offset + 3] & 0x7f) as u32;
let has_padding = (buffer[*offset + 3] & 0x80) != 0;
let bytes = [buffer[*offset], buffer[*offset + 1], buffer[*offset + 2], buffer[*offset + 3]];
let value = u32::from_be_bytes(bytes);
let has_padding = (value & 0x80000000) != 0;
let tag_size = value & 0x7FFFFFFF;
*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)?;
@@ -98,10 +100,8 @@ impl ValdiModule {
let mut tag_buffer = Vec::new();
fn write_u32(buffer: &mut Vec<u8>, value: u32, has_padding: bool) {
buffer.push(value as u8);
buffer.push(((value >> 8) & 0xff) as u8);
buffer.push(((value >> 16) & 0xff) as u8);
buffer.push(((value >> 24) & 0x7f) as u8 | if has_padding { 0x80 } else { 0x00 });
let encoded_value = (value & 0x7FFFFFFF) | if has_padding { 0x80000000 } else { 0 };
buffer.extend_from_slice(&encoded_value.to_be_bytes());
}
fn write_tag(buffer: &mut Vec<u8>, tag: ModuleTag) {
@@ -125,7 +125,7 @@ impl ValdiModule {
let mut buffer = Vec::new();
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

View File

@@ -16,7 +16,10 @@ def_hook!(
if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) {
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)) {
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,
*mut c_void,
|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();
if !handle.is_null() && path.starts_with("bridge_observables") {
let asset_buffer = aasset_get_buffer_original.unwrap()(handle);
let asset_length = aasset_get_length_original.unwrap()(handle);
debug!("asset buffer: {:p}, length: {}", asset_buffer, asset_length);
let handle = original_fn(arg0, arg1, arg2);
if handle.is_null() {
return handle;
}
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 decompressed = zstd::stream::decode_all(&archive_buffer[..]).expect("Failed to decompress valdi archive");
let mut valdi_module = ValdiModule::parse(decompressed).expect("Failed to parse valdi module");
let mut tags = valdi_module.get_tags();
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;
let asset_buffer = get_buffer_fn(handle);
let asset_length = get_length_fn(handle);
if asset_buffer.is_null() || asset_length <= 0 {
return handle;
}
tags.extend(new_tags);
valdi_module.set_tags(tags);
let loader_data = match LOADER_DATA.lock().unwrap().clone() {
Some(data) => data,
None => {
warn!("Valdi loader data not yet initialized for {}", path);
return handle;
}
};
let compressed = valdi_module.to_bytes();
let compressed = zstd::stream::encode_all(&compressed[..], 3).expect("Failed to compress");
let archive_buffer: Vec<u8> = unsafe {
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
}
@@ -89,16 +131,19 @@ def_hook!(
def_hook!(
aasset_close,
c_void,
(),
|handle: *mut c_void| {
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) {
let new_code = get_jni_string(&mut env, code).expect("Failed to get loader code");
LOADER_DATA.lock().unwrap().replace(new_code);
if let Ok(new_code) = get_jni_string(&mut env, code) {
LOADER_DATA.lock().unwrap().replace(new_code);
}
}
pub fn init() {

View File

@@ -1,5 +1,12 @@
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(
@JvmField
val disableBitmoji: Boolean = false,
@@ -9,4 +16,6 @@ data class NativeConfig(
val valdiHooks: Boolean = false,
@JvmField
val customEmojiFontPath: String? = null,
@JvmField
val debugFontRedirect: Boolean = false,
)