Auto Open Engine refactor and Media Downloader stabilization.
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
36
native/rust/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user