This commit is contained in:
RSR/
2026-04-18 02:26:47 +04:00
57 changed files with 2077 additions and 1637 deletions

4
.gitignore vendored
View File

@@ -4,9 +4,11 @@ local.properties
/.idea/
.DS_Store
/build
**/build/
/captures
.externalNativeBuild
.cxx
.local/
native/.omvll/
cloudflare/**/.wrangler/
cloudflare/**/node_modules/
@@ -20,4 +22,4 @@ security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs
.vs

View File

@@ -1 +1 @@
- All users are recommended to use the new Performance Mode feature! Go to the features tab and then select global and select performance mode and set it to Max. Then force stop and reopen Snapchat and you will feel the difference i.e. Snapchat will feel a lot faster.
- Temporary fix for conversations disappearing: Turn off Block Ads feature

View File

@@ -229,7 +229,6 @@ class BridgeService : Service() {
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(parsedFriends, parsedGroups)
return
}
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)

View File

@@ -121,6 +121,7 @@ class DownloadProcessor (
inputFile.outputStream().use {
bitmap.compress(compressFormat, 100, it)
}
bitmap.recycle()
fileType = FileType.fromFile(inputFile)
}
}
@@ -648,8 +649,45 @@ 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)
}
originalBitmap.recycle()
overlayBitmap.recycle()
mergedBitmap.recycle()
saveMediaToGallery(pendingTask, mergedImage, downloadMetadata)
mergedImage.delete()
renamedOverlayMedia.delete()
renamedMedia.delete()
return@launch
}.onFailure {
remoteSideContext.log.error("Failed to merge image overlay using Bitmap, falling back to FFmpeg", it)
}
}
val mergedOverlay: File = File.createTempFile("merged", ".mp4")
runCatching {
callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension))

View File

@@ -90,6 +90,12 @@ class FFMpegProcessor(
)
private val sharedExecutor = Executors.newSingleThreadExecutor()
protected fun finalize() {
runCatching { sharedExecutor.shutdown() }
}
private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> {
val stringBuilder = StringBuilder()
arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
@@ -127,7 +133,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 +168,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 +193,47 @@ class FFMpegProcessor(
}.getOrNull()?.let { file to it }
}
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
}?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
try {
val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0
}?.let { (_, r) ->
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to
r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} ?: throw Exception("Failed to get video size")
val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder()
var containsNoSound = false
val filterFirstPart = StringBuilder()
val filterSecondPart = StringBuilder()
var containsNoSound = false
filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}]")
filesInfo.forEachIndexed { index, (file, retriever) ->
filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];")
if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") {
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}]")
}
inputArguments += "-i" to file
}
inputArguments += "-i" to file
if (containsNoSound) {
inputArguments += "-f" to "lavfi"
inputArguments += "-t" to "0.1"
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
}
if (outputArguments["-c:a"] == "copy") {
outputArguments -= "-c:a"
}
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
} finally {
filesInfo.forEach { it.second.close() }
}
if (containsNoSound) {
inputArguments += "-f" to "lavfi"
inputArguments += "-t" to "0.1"
inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100"
}
if (outputArguments["-c:a"] == "copy") {
outputArguments -= "-c:a"
}
outputArguments += "-fps_mode" to "vfr"
outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-map" to "\"[vout]\""
filesInfo.forEach { it.second.close() }
}
Action.DOWNLOAD_AUDIO_STREAM -> {
outputArguments.clear()

View File

@@ -4,6 +4,9 @@ import me.eternal.purrfectsnap.common.data.FriendStreaks
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.isStealthRule
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
import me.eternal.purrfectsnap.common.util.ktx.getInteger
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
@@ -135,6 +138,11 @@ fun AppDatabase.replaceMessagingData(
} finally {
database.endTransaction()
}
// Notify with the full updated list from the DB
val allFriends = getFriends(descOrder = true)
val allGroups = getGroups()
receiveMessagingDataCallback(allFriends, allGroups)
}
}
@@ -154,12 +162,42 @@ fun AppDatabase.getRules(targetUuid: String): List<MessagingRuleType> {
context.log.error("Failed to parse rule", it)
}
}
rules
rules.normalizeStealthRules().toList()
}
}
fun AppDatabase.setRule(targetUuid: String, type: String, enabled: Boolean) {
executeAsync {
val ruleType = MessagingRuleType.getByName(type)
if (ruleType?.isStealthRule() == true) {
val updatedStealthRules = getRules(targetUuid)
.withNormalizedRuleToggle(ruleType, enabled)
.filter { it.isStealthRule() }
database.beginTransaction()
try {
database.execSQL(
"DELETE FROM rules WHERE targetUuid = ? AND type IN (?, ?, ?)",
arrayOf(
targetUuid,
MessagingRuleType.STEALTH.key,
MessagingRuleType.SNAP_STEALTH.key,
MessagingRuleType.CHAT_STEALTH.key
)
)
updatedStealthRules.forEach { stealthRule ->
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",
arrayOf(targetUuid, stealthRule.key)
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
return@executeAsync
}
if (enabled) {
database.execSQL(
"INSERT OR REPLACE INTO rules (targetUuid, type) VALUES (?, ?)",

View File

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

View File

@@ -779,11 +779,14 @@ class FeaturesRootSection : Routes.Route() {
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
val isMessageListProperty = property.key.name.endsWith("_messages")
val isSleepWindowProperty = property.key.name.contains("sleep_window")
val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date"
if (isMessageListProperty) {
alertDialogs.MessageListPropertyDialog(property) { showDialog = false }
} else if (isSleepWindowProperty) {
alertDialogs.AutoOpenScheduleDialog(property as PropertyPair<String>) { showDialog = false }
} else if (isSnapchatPlusPurchaseDateProperty) {
alertDialogs.DatePickerPropertyDialog(property) { showDialog = false }
} else {
alertDialogs.KeyboardInputDialog(property) { showDialog = false }
}
@@ -801,6 +804,7 @@ class FeaturesRootSection : Routes.Route() {
)
} else {
val isMessageListProperty = property.key.name.endsWith("_messages")
val isSnapchatPlusPurchaseDateProperty = property.key.name == "snapchat_plus_purchase_date"
if (isMessageListProperty) {
val messageCount = try {
val messageList: List<String> = gson.fromJson(propertyValue.get().toString(), listTypeToken) ?: emptyList()
@@ -822,6 +826,16 @@ class FeaturesRootSection : Routes.Route() {
color = Color.White
)
}
} else if (isSnapchatPlusPurchaseDateProperty) {
Button(
onClick = click,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
Text(translation["button.set"] ?: "Set")
}
} else {
IconButton(onClick = click) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)

View File

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

View File

@@ -256,11 +256,7 @@ class AddFriendDialog(
)
}
if (context.bridgeService != null) {
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
} else {
context.database.receiveMessagingDataCallback = updateSnapshot
}
context.database.receiveMessagingDataCallback = updateSnapshot
context.requestSocialSnapshotRefresh()
coroutineScope.launch(Dispatchers.IO) {

View File

@@ -37,6 +37,8 @@ import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.data.normalizeStealthRules
import me.eternal.purrfectsnap.common.data.withNormalizedRuleToggle
import me.eternal.purrfectsnap.common.ui.AutoClearKeyboardFocus
import me.eternal.purrfectsnap.common.ui.EditNoteTextField
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
@@ -222,18 +224,30 @@ class ManageScope: Routes.Route() {
Spacer(modifier = Modifier.height(16.dp))
val rules = rememberAsyncMutableStateList(listOf()) {
context.database.getRules(id)
context.database.getRules(id).normalizeStealthRules().toList()
}
fun updateRules(ruleType: MessagingRuleType, enabled: Boolean) {
val previousRules = rules.toSet()
val updatedRules = previousRules.withNormalizedRuleToggle(ruleType, enabled)
val changedRules = (previousRules + updatedRules).filter { rule ->
(rule in previousRules) != (rule in updatedRules)
}
rules.clear()
rules.addAll(updatedRules)
changedRules.forEach { changedRule ->
context.database.setRule(id, changedRule.key, changedRule in updatedRules)
}
}
SectionTitle(translation["rules_title"])
ContentCard {
MessagingRuleType.entries.forEach { ruleType ->
var ruleEnabled by remember(rules.size) {
mutableStateOf(rules.any { it.key == ruleType.key })
}
val ruleState = context.config.root.rules.getRuleState(ruleType)
val ruleEnabled = rules.any { it.key == ruleType.key }
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -252,8 +266,7 @@ class ManageScope: Routes.Route() {
checked = ruleEnabled,
enabled = if (ruleType.listMode) ruleState != null else true,
onCheckedChange = {
context.database.setRule(id, ruleType.key, it)
ruleEnabled = it
updateRules(ruleType, it)
},
colors = purrfectSwitchColors()
)

View File

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

View File

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

View File

@@ -75,6 +75,10 @@ import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Overlay
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
@@ -512,6 +516,68 @@ class AlertDialogs(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePickerPropertyDialog(property: PropertyPair<*>, dismiss: () -> Unit = {}) {
val context = LocalContext.current
val zoneId = remember { ZoneId.systemDefault() }
val initialSelectedDateMillis = remember(property.value.get()) {
runCatching {
LocalDate
.parse(property.value.get().toString(), DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli()
}.getOrNull()
}
val datePickerState = rememberDatePickerState(initialSelectedDateMillis = initialSelectedDateMillis)
DefaultDialogCard {
DatePicker(
state = datePickerState,
showModeToggle = true
)
Row(
modifier = Modifier
.padding(top = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
) {
Button(
onClick = { dismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
val selectedDate = datePickerState.selectedDateMillis?.let {
Instant.ofEpochMilli(it).atZone(zoneId).toLocalDate()
}
if (selectedDate == null) {
Toast.makeText(context, translation["invalid_input_toast"], Toast.LENGTH_SHORT).show()
return@Button
}
property.value.setAny(selectedDate.format(DateTimeFormatter.ISO_LOCAL_DATE))
dismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.32f),
contentColor = Color.White
)
) {
Text(text = translation["button.ok"])
}
}
}
}
@Composable
fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) {
val focusRequester = remember { FocusRequester() }
@@ -1615,4 +1681,3 @@ class AlertDialogs(
}
}
}

View File

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

View File

@@ -1,3 +1,14 @@
## v1.6.9
- New: Updated the Stealth mode for better visibility with the chat stealth mode (keeps chats from being read), and snap stealth-mode and full stealth mode toggle (normal stealth-mode). (tq Javalsta)
- Fix: Fixed performance mode profile save/load so Disabled persists correctly and no longer falls back to Max mode on app restart. (tq schrodingerspet)
- Fix: Fixed the resume/reopen UI break when max performance mode is turned on, and other bug fixes. (tq schrodingerspet)
- New: Implemented an Auto Open Stop Button directly within the notification card.
- New: Added a log filter menu in logs page to isolate Auto-Open, Media downloads, friend tracker, and Core logs.
- Fix: Completely rewritten Auto Open Engine to optimize the auto open engine.
- Fix: Fixed Batch Story Download feature not working.
- FIx: Minor bug fixes for media downloader in stories and spotlight.
- Fix: Bug fixes to improve custom emojis stability.
## v1.6.8
- Fix: Many improvements to the Performance Mode feature(Max, turned on by Default), changes are pretty noticeable: faster loading of chats, long group messages optimizations, many snapmap optimizations
- Fix: Crash issues for some devices

View File

@@ -779,11 +779,27 @@
}
},
"stealth": {
"name": "Stealth Mode",
"description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations",
"name": "Full Stealth Mode",
"description": "Applies both chat stealth and snap stealth for this conversation",
"options": {
"blacklist": "Exclude from Stealth Mode",
"whitelist": "Stealth mode"
"blacklist": "Exclude from Full Stealth Mode",
"whitelist": "Full Stealth Mode"
}
},
"snap_stealth": {
"name": "Snap Stealth Mode",
"description": "Prevents anyone from knowing you've opened their snaps",
"options": {
"blacklist": "Exclude from Snap Stealth Mode",
"whitelist": "Snap Stealth Mode"
}
},
"chat_stealth": {
"name": "Chat Stealth Mode",
"description": "Prevents anyone from knowing you've opened their chats or viewed their chat presence",
"options": {
"blacklist": "Exclude from Chat Stealth Mode",
"whitelist": "Chat Stealth Mode"
}
},
"auto_save": {
@@ -1885,7 +1901,13 @@
"name": "Auto Download"
},
"stealth": {
"name": "Stealth Mode"
"name": "Full Stealth Mode"
},
"snap_stealth": {
"name": "Snap Stealth Mode"
},
"chat_stealth": {
"name": "Chat Stealth Mode"
},
"auto_save": {
"name": "Auto Save"
@@ -2291,7 +2313,7 @@
"auto_save": "\ud83d\udcac Auto Save Messages",
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
"stealth": "\ud83d\udc7b Stealth Mode",
"stealth": "\ud83d\udc7b Full Stealth Mode",
"auto_reply": "\ud83d\udce8 Auto Reply",
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
"mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen",
@@ -2385,6 +2407,16 @@
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"snap_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"chat_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"auto_save": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",

View File

@@ -256,6 +256,15 @@
"home_logs": {
"no_logs_hint": "No logs available",
"refresh_hint": "Pull to refresh or trigger an action to see new entries.",
"filter_logs_title": "Filter Log Categories",
"filter_logs_menu_item": "Filter Logs",
"filter_logs_done_button": "Done",
"log_category_core": "Core",
"log_category_auto_open": "Auto-Open",
"log_category_media": "Media",
"log_category_bridge": "Bridge",
"log_category_system": "System",
"log_category_tracker": "Tracker",
"clear_logs_button": "Clear Logs",
"export_logs_button": "Export Logs",
"saving_logs_toast": "Saving logs, this may take a while ...",
@@ -818,11 +827,27 @@
}
},
"stealth": {
"name": "Stealth Mode",
"description": "Prevents anyone from knowing you've opened their Snaps/Chats and conversations",
"name": "Full Stealth Mode",
"description": "Applies both chat stealth and snap stealth for this conversation",
"options": {
"blacklist": "Exclude from Stealth Mode",
"whitelist": "Stealth mode"
"blacklist": "Exclude from Full Stealth Mode",
"whitelist": "Full Stealth Mode"
}
},
"snap_stealth": {
"name": "Snap Stealth Mode",
"description": "Prevents anyone from knowing you've opened their snaps",
"options": {
"blacklist": "Exclude from Snap Stealth Mode",
"whitelist": "Snap Stealth Mode"
}
},
"chat_stealth": {
"name": "Chat Stealth Mode",
"description": "Prevents anyone from knowing you've opened their chats or viewed their chat presence",
"options": {
"blacklist": "Exclude from Chat Stealth Mode",
"whitelist": "Chat Stealth Mode"
}
},
"auto_save": {
@@ -1215,6 +1240,10 @@
"name": "Settings Menu",
"description": "Choose between the new and legacy settings menu layouts"
},
"chat_hold_kill_actions": {
"name": "PurrfectSnap Chat Hold Kill",
"description": "Hold the chat/settings header button to kill selected app(s). Leave all options off to disable."
},
"spoof_snap_score": {
"name": "Spoof Snap Score",
"description": "Spoof your Snap Score (local only)",
@@ -1660,6 +1689,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"
@@ -1886,6 +1923,10 @@
"name": "Snapchat Plus",
"description": "Enables Snapchat Plus features\nSome Server-sided features may not work"
},
"snapchat_plus_purchase_date": {
"name": "Snapchat Plus Purchase Date",
"description": "Tap Save to choose a date from calendar (leave empty to use default)"
},
"media_upload_quality": {
"name": "Media Upload Quality",
"description": "Overrides the media upload quality",
@@ -2012,7 +2053,13 @@
"name": "Auto Download"
},
"stealth": {
"name": "Stealth Mode"
"name": "Full Stealth Mode"
},
"snap_stealth": {
"name": "Snap Stealth Mode"
},
"chat_stealth": {
"name": "Chat Stealth Mode"
},
"auto_save": {
"name": "Auto Save"
@@ -2143,6 +2190,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"
@@ -2820,7 +2871,7 @@
"auto_save": "\ud83d\udcac Auto Save Messages",
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
"stealth": "\ud83d\udc7b Stealth Mode",
"stealth": "\ud83d\udc7b Full Stealth Mode",
"auto_reply": "\ud83d\udce8 Auto Reply",
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
"mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read",
@@ -2862,6 +2913,10 @@
"default": "Default",
"legacy": "Legacy"
},
"chat_hold_kill_actions": {
"kill_snapchat": "Kill Snapchat",
"kill_purrfectsnap": "Kill PurrfectSnap"
},
"path_format": {
"create_author_folder": "Create folder for each author",
"create_source_folder": "Create folder for each media source type",
@@ -2927,6 +2982,16 @@
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"snap_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"chat_stealth": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
"disabled": "Disabled"
},
"auto_save": {
"blacklist": "Blacklist",
"whitelist": "Whitelist",
@@ -3570,6 +3635,7 @@
"cancel": "Cancel",
"copy": "Copy",
"save": "Save",
"set": "Set",
"open": "Open",
"download": "Download",
"import": "Import",
@@ -3693,6 +3759,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": {
@@ -4353,8 +4420,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",
@@ -4363,8 +4429,8 @@
"tasks_clear_button_description": "Clear tasks",
"tasks_delete_button": "Delete",
"tasks_merge_button": "Merge",
"tasks_summary_active": "{active} active · {recent} recent",
"tasks_summary_idle": "Idle · {recent} recent",
"tasks_summary_active": "{active} active \u2022 {recent} recent",
"tasks_summary_idle": "Idle \u2022 {recent} recent",
"tasks_running_count": "{count} running",
"tasks_tagline": "Monitor and manage background actions",
"tasks_failed_to_open_file": "Failed to open file",

View File

@@ -1,6 +1,7 @@
package me.eternal.purrfectsnap.common.config
import android.content.Context
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import me.eternal.purrfectsnap.common.logger.AbstractLogger
import kotlin.reflect.KProperty
@@ -79,7 +80,9 @@ open class ConfigContainer(
properties.forEach { (propertyKey, propertyValue) ->
if (!exportSensitiveData && propertyKey.params.flags.contains(ConfigFlag.SENSITIVE)) return@forEach
if (!includeSavedLocations && propertyKey.dataType.type == DataProcessors.Type.MAP_COORDINATES) return@forEach
val serializedValue = propertyValue.getRaw()?.let { propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations) }
val serializedValue = propertyValue.getRaw()?.let {
propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations)
} ?: JsonNull.INSTANCE
json.add(propertyKey.name, serializedValue)
}
return json

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ package me.eternal.purrfectsnap.common.config.impl
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.ConfigFlag
import me.eternal.purrfectsnap.common.config.FeatureNotice
import java.time.LocalDate
import java.time.format.DateTimeFormatter
class Global : ConfigContainer() {
companion object {
@@ -46,6 +48,12 @@ class Global : ConfigContainer() {
val betterLocation = container("better_location", BetterLocationConfig())
val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() }
val snapchatPlusPurchaseDate = string("snapchat_plus_purchase_date", "") {
requireRestart()
inputCheck = {
it.isBlank() || runCatching { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }.isSuccess
}
}
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply {
profile.set("max")

View File

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

View File

@@ -63,6 +63,7 @@ class UserInterfaceTweaks : ConfigContainer() {
}
val preventForcedKeyboard = boolean("prevent_forced_keyboard") { requireRestart() }
val settingsMenu = unique("settings_menu", "default", "legacy") { requireRestart() }.apply { set("default") }
val chatHoldKillActions = multiple("chat_hold_kill_actions", "kill_snapchat", "kill_purrfectsnap") { requireRestart() }
inner class SpoofSnapScore : ConfigContainer(hasGlobalState = true) {
val customSnapScore = string("custom_snap_score") {

View File

@@ -49,6 +49,8 @@ enum class MessagingRuleType(
val configNotices: Array<FeatureNotice> = emptyArray()
) {
STEALTH("stealth", true, Icons.Outlined.TrackChanges),
SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = false),
CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = false),
HIDE_TYPING_INDICATOR("hide_typing_indicator", true, Icons.Outlined.KeyboardHide, defaultValue = "whitelist"),
AUTO_DOWNLOAD("auto_download", true, Icons.Outlined.DownloadForOffline),
AUTO_SAVE("auto_save", true, Icons.Outlined.Save, defaultValue = "blacklist"),
@@ -71,6 +73,58 @@ enum class MessagingRuleType(
}
}
private val partialStealthRuleTypes = setOf(
MessagingRuleType.SNAP_STEALTH,
MessagingRuleType.CHAT_STEALTH
)
private val allStealthRuleTypes = partialStealthRuleTypes + MessagingRuleType.STEALTH
fun MessagingRuleType.isStealthRule(): Boolean = this in allStealthRuleTypes
fun Collection<MessagingRuleType>.normalizeStealthRules(): Set<MessagingRuleType> {
val normalizedRules = toMutableSet()
if (MessagingRuleType.STEALTH in normalizedRules) {
normalizedRules.removeAll(partialStealthRuleTypes)
return normalizedRules
}
if (partialStealthRuleTypes.all { it in normalizedRules }) {
normalizedRules.removeAll(partialStealthRuleTypes)
normalizedRules.add(MessagingRuleType.STEALTH)
}
return normalizedRules
}
fun Collection<MessagingRuleType>.withNormalizedRuleToggle(
ruleType: MessagingRuleType,
enabled: Boolean
): Set<MessagingRuleType> {
val updatedRules = toMutableSet().apply {
if (enabled) {
add(ruleType)
} else {
remove(ruleType)
}
}
if (!ruleType.isStealthRule()) {
return updatedRules
}
if (enabled) {
when (ruleType) {
MessagingRuleType.STEALTH -> updatedRules.removeAll(partialStealthRuleTypes)
MessagingRuleType.SNAP_STEALTH,
MessagingRuleType.CHAT_STEALTH -> updatedRules.remove(MessagingRuleType.STEALTH)
else -> Unit
}
}
return updatedRules.normalizeStealthRules()
}
@Parcelize
data class FriendStreaks(
val notify: Boolean = true,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package me.eternal.purrfectsnap.core.features.impl
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.Hooker
@@ -165,7 +166,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() },
{ true }, isAppExperiment = true)
overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() },
overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", {
context.config.userInterface.disableSpotlight.get() &&
context.feature(Messaging::class).openedConversationUUID == null
},
{ false })
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },

View File

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

View File

@@ -8,9 +8,11 @@ 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 me.eternal.purrfectsnap.mapper.impl.PlusSubscriptionMapper
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
class SnapchatPlus: Feature("SnapchatPlus") {
private val originalSubscriptionTime = (System.currentTimeMillis() - 7776000000L)
private val expirationTimeMillis = (System.currentTimeMillis() + 15552000000L)
override fun init() {
@@ -40,7 +42,24 @@ class SnapchatPlus: Feature("SnapchatPlus") {
//subscription status
set(statusField.getAsString()!!, 2)
set(originalSubscriptionTimeMillisField.getAsString()!!, originalSubscriptionTime)
val fallbackOriginalSubscriptionTime = System.currentTimeMillis() - 7776000000L
val customPurchaseDate = context.config.global.snapchatPlusPurchaseDate.get().trim()
val customPurchaseDateMillis = if (customPurchaseDate.isNotEmpty()) {
runCatching {
LocalDate
.parse(customPurchaseDate, DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}.getOrNull()
} else {
null
}
set(
originalSubscriptionTimeMillisField.getAsString()!!,
customPurchaseDateMillis ?: fallbackOriginalSubscriptionTime
)
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
}
}

View File

@@ -257,8 +257,8 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
val snapManager = context.feature(Messaging::class).snapManager ?: return@hook
val stealthMode = context.feature(StealthMode::class)
// ignore non-stealth mode conversations
if (!stealthMode.canUseRule(conversationId.toString())) return@hook
// ignore conversations without snap stealth enabled
if (!stealthMode.canUseSnapStealth(conversationId.toString())) return@hook
stealthMode.addSnapInteractionException(clientMessageId)

View File

@@ -62,7 +62,7 @@ class AutoSave : MessagingRuleFeature("Auto Save", MessagingRuleType.AUTO_SAVE)
if (messaging.openedConversationUUID?.toString() != targetConversationId) return false
}
if (context.feature(StealthMode::class).canUseRule(targetConversationId)) return false
if (context.feature(StealthMode::class).canUseChatStealth(targetConversationId)) return false
return canUseRule(targetConversationId)
}

View File

@@ -57,7 +57,7 @@ class Messaging : Feature("Messaging") {
private fun shouldHideBitmojiPresence(stealthMode: StealthMode): Boolean {
return context.config.messaging.hideBitmojiPresence.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) } == true
}
private fun shouldSpoofViewingGalleryPresence(stealthMode: StealthMode): Boolean {
@@ -70,12 +70,12 @@ class Messaging : Feature("Messaging") {
private fun shouldHideTyping(stealthMode: StealthMode, hideTypingIndicator: HideTypingIndicator): Boolean {
return context.config.messaging.hideTypingNotifications.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) || hideTypingIndicator.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) || hideTypingIndicator.canUseRule(it) } == true
}
private fun shouldHidePeek(stealthMode: StealthMode): Boolean {
return context.config.messaging.hidePeekAPeek.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
currentConversationId()?.let { stealthMode.canUseChatStealth(it) } == true
}
private fun clearField(instance: Any, typeNamePart: String, shouldClear: Boolean) {
@@ -361,4 +361,3 @@ class Messaging : Feature("Messaging") {
return (future.get() as? List<*>)?.map { Snapchatter(it) } ?: return emptyList()
}
}

View File

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

View File

@@ -1,16 +1,55 @@
package me.eternal.purrfectsnap.core.features.impl.spying
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.ConcurrentHashMap
class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALTH) {
private val displayedMessageQueue = CopyOnWriteArraySet<Long>()
private val snapInteractionQueue = CopyOnWriteArraySet<Long>()
private val ruleSnapshotCache = ConcurrentHashMap<String, Pair<Long, Set<MessagingRuleType>>>()
private fun getTargetId(conversationId: String): String {
return context.database.getDMOtherParticipant(conversationId) ?: conversationId
}
private fun getRuleSnapshot(conversationId: String): Set<MessagingRuleType> {
val targetId = getTargetId(conversationId)
val now = System.currentTimeMillis()
ruleSnapshotCache[targetId]?.takeIf { now - it.first < 1_000L }?.let { return it.second }
return context.bridgeClient.getRules(targetId).toSet().also { rules ->
ruleSnapshotCache[targetId] = now to rules
}
}
private fun isRuleActive(
conversationId: String,
ruleType: MessagingRuleType
): Boolean {
val ruleState = context.config.rules.getRuleState(ruleType) ?: return false
val enabled = ruleType in getRuleSnapshot(conversationId)
return if (ruleState == RuleState.BLACKLIST) !enabled else enabled
}
fun canUseChatStealth(conversationId: String): Boolean {
return isRuleActive(conversationId, MessagingRuleType.STEALTH) ||
isRuleActive(conversationId, MessagingRuleType.CHAT_STEALTH)
}
fun canUseSnapStealth(conversationId: String): Boolean {
return isRuleActive(conversationId, MessagingRuleType.STEALTH) ||
isRuleActive(conversationId, MessagingRuleType.SNAP_STEALTH)
}
fun isAnyStealthEnabled(conversationId: String): Boolean {
return canUseChatStealth(conversationId) || canUseSnapStealth(conversationId)
}
fun addDisplayedMessageException(clientMessageId: Long) {
displayedMessageQueue.add(clientMessageId)
@@ -22,12 +61,10 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT
override fun init() {
val isConversationInStealthMode: (SnapUUID) -> Boolean = { canUseRule(it.toString()) }
arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String ->
context.classCache.conversationManager.hook(methodName, HookStage.BEFORE) { param ->
if (displayedMessageQueue.removeIf { param.arg<Long>(1) == it }) return@hook
if (isConversationInStealthMode(SnapUUID(param.arg(0)))) {
if (canUseChatStealth(SnapUUID(param.arg(0)).toString())) {
param.setResult(null)
}
}
@@ -35,7 +72,7 @@ class StealthMode : MessagingRuleFeature("StealthMode", MessagingRuleType.STEALT
context.event.subscribe(OnSnapInteractionEvent::class) { event ->
if (snapInteractionQueue.removeIf { event.messageId == it }) return@subscribe
if (isConversationInStealthMode(event.conversationId)) {
if (canUseSnapStealth(event.conversationId.toString())) {
event.canceled = true
}
}

View File

@@ -11,7 +11,6 @@ import android.media.MediaRecorder
import android.os.HandlerThread
import android.os.Process
import android.util.Base64
import android.util.Range
import android.view.View
import android.widget.OverScroller
import androidx.recyclerview.widget.LinearLayoutManager
@@ -21,7 +20,6 @@ import java.io.File
import java.lang.Thread
import java.lang.reflect.Method
import java.util.LinkedHashMap
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.ThreadPoolExecutor
@@ -44,6 +42,8 @@ class PerformanceMode : Feature("Performance Mode") {
private const val CHAT_FEED_CACHE_MAX_ROWS = 400
private const val CHAT_FEED_CACHE_MAX_BLOB_BYTES = 512
private const val CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS = 15_000L
private const val CHAT_FEED_CACHE_MAX_AGE_MS = 5L * 60L * 1000L
private const val CHAT_FEED_CACHE_SCHEMA_VERSION = 2
private const val MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS = 64
private const val MESSAGE_WINDOW_STATE_MAX_AGE_MS = 7L * 24L * 60L * 60L * 1000L
private const val SNAP_PREFETCH_GROUP_MESSAGES = 48
@@ -65,6 +65,13 @@ class PerformanceMode : Feature("Performance Mode") {
val rows: List<List<SnapshotCell>>,
)
private data class ChatFeedSnapshotCache(
val schemaVersion: Int,
val queryKey: String,
val createdAt: Long,
val snapshot: CursorSnapshot,
)
private data class MessageWindowState(
val conversationId: String,
val currentSize: Int,
@@ -156,6 +163,22 @@ class PerformanceMode : Feature("Performance Mode") {
val lastChatFeedSnapshotWrite = AtomicLong(0L)
val chatFeedSnapshotServedThisProcess = AtomicBoolean(false)
fun invalidateChatFeedSnapshot(reason: String) {
val deleted = runCatching {
if (!chatFeedSnapshotFile.exists()) return@runCatching false
chatFeedSnapshotFile.delete()
}.getOrDefault(false)
chatFeedSnapshotServedThisProcess.set(false)
if (deleted) {
context.log.info("Invalidated chat feed snapshot ($reason)", "PerformanceMode")
}
}
Activity::class.java.hook("onResume", HookStage.AFTER) {
if (!isMaxProfile) return@hook
chatFeedSnapshotServedThisProcess.set(false)
}
val windowStatePrefs = context.androidContext.getSharedPreferences("purrfectsnap_perf_message_windows", Context.MODE_PRIVATE)
val messageWindowStates = runCatching {
val raw = windowStatePrefs.getString("states", null).orEmpty()
@@ -177,16 +200,32 @@ class PerformanceMode : Feature("Performance Mode") {
}
}
val snapshotQueryWhitespaceRegex = Regex("\\s+")
fun buildChatFeedSnapshotQueryKey(sql: String): String {
return sql.lowercase()
.replace(snapshotQueryWhitespaceRegex, " ")
.trim()
}
fun isChatFeedQuery(sql: String): Boolean {
val normalized = sql.uppercase()
if (!normalized.startsWith("SELECT")) return false
val hitsFriendsFeedView = sql.contains("FriendsFeedView")
val hitsFeedEntry = sql.contains("feed_entry") && (sql.contains("last_updated_timestamp") || sql.contains("displayInteractionType") || sql.contains("streak_count"))
return (hitsFriendsFeedView || hitsFeedEntry) &&
!normalized.contains("COUNT(") &&
!normalized.contains("SELECT 0") &&
!normalized.contains("WHERE KEY = ?") &&
!normalized.contains("WHERE CLIENT_CONVERSATION_ID = ?")
val normalized = buildChatFeedSnapshotQueryKey(sql)
if (!normalized.startsWith("select ")) return false
val isFriendsFeedViewQuery =
normalized.startsWith("select * from friendsfeedview ") &&
normalized.contains(" order by _id ") &&
normalized.contains(" limit ")
val isFeedEntryQuery =
normalized.startsWith("select * from feed_entry ") &&
normalized.contains(" order by last_updated_timestamp desc ") &&
normalized.contains(" limit ")
return (isFriendsFeedViewQuery || isFeedEntryQuery) &&
!normalized.contains("count(") &&
!normalized.contains("select 0") &&
!normalized.contains("where key = ?") &&
!normalized.contains("where client_conversation_id = ?")
}
fun cursorCell(cursor: Cursor, index: Int): SnapshotCell {
@@ -205,17 +244,33 @@ class PerformanceMode : Feature("Performance Mode") {
}
}
fun snapshotFromCursor(cursor: Cursor): CursorSnapshot {
val columns = cursor.columnNames.toList()
val rows = mutableListOf<List<SnapshotCell>>()
if (cursor.moveToFirst()) {
var rowCount = 0
do {
rows += columns.indices.map { index -> cursorCell(cursor, index) }
rowCount++
} while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext())
fun snapshotFromCursor(cursor: Cursor): CursorSnapshot? {
val originalPosition = cursor.position
val snapshot = runCatching {
val columns = cursor.columnNames.toList()
val rows = mutableListOf<List<SnapshotCell>>()
if (cursor.moveToFirst()) {
var rowCount = 0
do {
rows += columns.indices.map { index -> cursorCell(cursor, index) }
rowCount++
} while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext())
}
CursorSnapshot(columns, rows)
}.onFailure {
context.log.error("Failed to snapshot chat feed cursor", it, "PerformanceMode")
}.getOrNull()
runCatching { cursor.moveToPosition(originalPosition) }
val restoredPosition = runCatching { cursor.position }.getOrNull()
if (restoredPosition != originalPosition) {
context.log.warn(
"Skipping chat feed snapshot write due non-restorable cursor position (from=$originalPosition to=${restoredPosition ?: "unknown"})",
"PerformanceMode"
)
return null
}
return CursorSnapshot(columns, rows)
return snapshot
}
fun snapshotToMatrixCursor(snapshot: CursorSnapshot): MatrixCursor {
@@ -234,16 +289,42 @@ class PerformanceMode : Feature("Performance Mode") {
}
}
fun readSnapshot(file: File): CursorSnapshot? {
fun readSnapshot(file: File, expectedQueryKey: String): CursorSnapshot? {
return runCatching {
if (!file.exists()) return null
context.gson.fromJson(file.readText(Charsets.UTF_8), CursorSnapshot::class.java)
}.getOrNull()
val cache = context.gson.fromJson(file.readText(Charsets.UTF_8), ChatFeedSnapshotCache::class.java) ?: return null
if (cache.schemaVersion != CHAT_FEED_CACHE_SCHEMA_VERSION) {
runCatching { file.delete() }
return null
}
if (cache.queryKey != expectedQueryKey) {
runCatching { file.delete() }
return null
}
if (System.currentTimeMillis() - cache.createdAt > CHAT_FEED_CACHE_MAX_AGE_MS) {
runCatching { file.delete() }
return null
}
cache.snapshot
}.getOrElse {
runCatching { file.delete() }
null
}
}
fun writeSnapshot(file: File, snapshot: CursorSnapshot) {
fun writeSnapshot(file: File, queryKey: String, snapshot: CursorSnapshot) {
runCatching {
file.writeText(context.gson.toJson(snapshot), Charsets.UTF_8)
file.writeText(
context.gson.toJson(
ChatFeedSnapshotCache(
schemaVersion = CHAT_FEED_CACHE_SCHEMA_VERSION,
queryKey = queryKey,
createdAt = System.currentTimeMillis(),
snapshot = snapshot,
)
),
Charsets.UTF_8
)
}.onFailure {
context.log.error("Failed to persist friend list snapshot", it, "PerformanceMode")
}
@@ -253,10 +334,7 @@ class PerformanceMode : Feature("Performance Mode") {
if (!isMaxProfile) return@subscribe
val url = event.url
if (url.contains("ami/friends")) {
if (chatFeedSnapshotFile.exists()) {
chatFeedSnapshotFile.delete()
context.log.info("Invalidated chat feed snapshot after friends mutation sync", "PerformanceMode")
}
invalidateChatFeedSnapshot("friends-mutation-sync")
}
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
event.canceled = true
@@ -608,7 +686,8 @@ class PerformanceMode : Feature("Performance Mode") {
val sql = param.argNullable<String>(1) ?: return@hook
if (!isChatFeedQuery(sql)) return@hook
if (chatFeedSnapshotServedThisProcess.get()) return@hook
readSnapshot(chatFeedSnapshotFile)?.let { snapshot ->
val queryKey = buildChatFeedSnapshotQueryKey(sql)
readSnapshot(chatFeedSnapshotFile, queryKey)?.let { snapshot ->
param.setResult(snapshotToMatrixCursor(snapshot))
chatFeedSnapshotServedThisProcess.set(true)
}
@@ -618,13 +697,13 @@ class PerformanceMode : Feature("Performance Mode") {
if (!isMaxProfile) return@hook
val sql = param.argNullable<String>(1) ?: return@hook
if (!isChatFeedQuery(sql)) return@hook
if (chatFeedSnapshotFile.exists()) return@hook
val cursor = param.getResult() as? Cursor ?: return@hook
val now = System.currentTimeMillis()
if (now - lastChatFeedSnapshotWrite.get() < CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS) return@hook
val snapshot = snapshotFromCursor(cursor)
val queryKey = buildChatFeedSnapshotQueryKey(sql)
val snapshot = snapshotFromCursor(cursor) ?: return@hook
if (snapshot.rows.isEmpty()) return@hook
writeSnapshot(chatFeedSnapshotFile, snapshot)
writeSnapshot(chatFeedSnapshotFile, queryKey, snapshot)
lastChatFeedSnapshotWrite.set(now)
}
}.onFailure {

View File

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

View File

@@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
@@ -31,7 +30,7 @@ class StealthModeIndicator : Feature("StealthModeIndicator") {
private fun requestUpdate(conversationId: String) {
fetchJob?.cancel()
fetchJob = context.coroutineScope.launch {
val isStealth = stealthMode.canUseRule(conversationId)
val isStealth = stealthMode.isAnyStealthEnabled(conversationId)
withContext(Dispatchers.Main) {
listener(isStealth)
}
@@ -52,9 +51,9 @@ class StealthModeIndicator : Feature("StealthModeIndicator") {
if (!context.config.userInterface.stealthModeIndicator.get()) return
onNextActivityCreate {
stealthMode.addStateListener { conversationId, state ->
stealthMode.addStateListener { conversationId, _ ->
runCatching {
listeners[conversationId]?.invoke(stealthMode.getRuleState()?.let { if (it == RuleState.BLACKLIST) !state else state } ?: state)
listeners[conversationId]?.invoke(stealthMode.isAnyStealthEnabled(conversationId))
}.onFailure {
context.log.error("Failed to update stealth mode indicator", it)
}
@@ -100,4 +99,4 @@ class StealthModeIndicator : Feature("StealthModeIndicator") {
}
}
}
}
}

View File

@@ -6,6 +6,7 @@ import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
@@ -69,10 +70,157 @@ class UITweaks : Feature("UITweaks") {
}
}
private fun findSpotlightNavTarget(
event: AddViewEvent,
spotlightNavIds: Set<Int>,
spotlightNavNames: Set<String>
): View? {
data class ViewMetadata(
val view: View,
val resourceEntryName: String?,
val contentDescription: String?,
val text: String?,
val className: String
)
fun resourceEntryNameOrNull(view: View): String? {
val id = view.id
if (id == View.NO_ID || id == 0) return null
return runCatching { context.resources.getResourceEntryName(id) }.getOrNull()
}
val markerKeywords = setOf("spotlight", "following", "discover")
val viewChain = buildList {
var current: View? = event.view
repeat(5) {
current ?: return@repeat
add(
ViewMetadata(
view = current!!,
resourceEntryName = resourceEntryNameOrNull(current!!),
contentDescription = current!!.contentDescription?.toString(),
text = (current as? TextView)?.text?.toString(),
className = current!!.javaClass.name
)
)
current = current?.parent as? View
}
}
fun isExactMatch(metadata: ViewMetadata): Boolean {
return metadata.view.id in spotlightNavIds ||
metadata.resourceEntryName in spotlightNavNames
}
fun hasMarker(metadata: ViewMetadata): Boolean {
return listOfNotNull(
metadata.resourceEntryName,
metadata.contentDescription,
metadata.text
).any { value ->
markerKeywords.any { keyword ->
value.contains(keyword, ignoreCase = true)
}
}
}
fun isNavigationLike(metadata: ViewMetadata): Boolean {
val resourceEntryName = metadata.resourceEntryName.orEmpty()
val className = metadata.className
return resourceEntryName.contains("hova_nav", ignoreCase = true) ||
resourceEntryName.contains("bottom_nav", ignoreCase = true) ||
resourceEntryName.contains("nav", ignoreCase = true) ||
resourceEntryName.contains("tab", ignoreCase = true) ||
className.contains("navigation", ignoreCase = true) ||
className.contains("bottom", ignoreCase = true) ||
className.contains("tab", ignoreCase = true) ||
className.contains("hova", ignoreCase = true)
}
if (viewChain.none(::isExactMatch) && viewChain.none(::hasMarker)) {
return null
}
var sawSpotlightMarker = false
viewChain.forEach { metadata ->
if (isExactMatch(metadata) || hasMarker(metadata)) {
sawSpotlightMarker = true
}
if (sawSpotlightMarker && isNavigationLike(metadata)) {
return metadata.view
}
}
return viewChain.firstOrNull(::isExactMatch)?.view
}
private fun findSpotlightHeaderTabsTarget(view: View): View? {
fun collectTextLabels(current: View, depth: Int = 0, maxDepth: Int = 2): List<String> {
if (depth > maxDepth) return emptyList()
val ownText = listOfNotNull(
current.contentDescription?.toString(),
(current as? TextView)?.text?.toString()
).filter { it.isNotBlank() }
if (current !is ViewGroup) return ownText
return ownText + current.children().flatMap { child ->
collectTextLabels(child, depth + 1, maxDepth)
}
}
fun isHeaderMarkerText(value: String): Boolean {
return value.contains("spotlight", ignoreCase = true) ||
value.contains("discover", ignoreCase = true) ||
value.contains("following", ignoreCase = true)
}
val candidateChain = buildList {
var current: View? = view
repeat(6) {
current ?: return@repeat
add(current!!)
current = current?.parent as? View
}
}
candidateChain.forEach { candidate ->
val group = candidate as? ViewGroup ?: return@forEach
if (group.childCount !in 2..4) return@forEach
val directMarkedChildren = group.children().count { child ->
collectTextLabels(child).any(::isHeaderMarkerText)
}
if (directMarkedChildren < 2) return@forEach
val texts = collectTextLabels(group)
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
val hasSpotlightOrDiscover = texts.any {
it.contains("spotlight", ignoreCase = true) ||
it.contains("discover", ignoreCase = true)
}
val hasFollowing = texts.any { it.contains("following", ignoreCase = true) }
if (hasSpotlightOrDiscover && hasFollowing) {
return group
}
}
return null
}
private fun onActivityCreate() {
val blockAds by context.config.global.blockAds
val hiddenElements by context.config.userInterface.hideUiComponents
val hideStorySuggestions by context.config.userInterface.hideStorySuggestions
val disableSpotlight by context.config.userInterface.disableSpotlight
val isImmersiveCamera by context.config.camera.immersiveCameraPreview
val displayMetrics = context.resources.displayMetrics
@@ -80,14 +228,36 @@ class UITweaks : Feature("UITweaks") {
val chatNoteRecordButton = getId("chat_note_record_button", "id")
val unreadHintButton = getId("unread_hint_button", "id")
val spotlightNavIds = listOf(
getId("hova_nav_spotlight", "id"),
getId("ngs_hova_nav_spotlight", "id"),
getId("hova_nav_spotlight_tab", "id"),
getId("hova_nav_spotlight_button", "id"),
getId("hova_nav_discover", "id"),
getId("ngs_hova_nav_discover", "id"),
getId("hova_nav_discover_tab", "id"),
getId("hova_nav_discover_button", "id")
).filter { it != 0 }.toSet()
val spotlightNavNames = setOf(
"hova_nav_spotlight",
"ngs_hova_nav_spotlight",
"hova_nav_spotlight_tab",
"hova_nav_spotlight_button",
"hova_nav_discover",
"ngs_hova_nav_discover",
"hova_nav_discover_tab",
"hova_nav_discover_button"
)
Resources::class.java.methods.first { it.name == "getDimensionPixelSize"}.hook(
Resources::class.java.methods.first { it.name == "getDimensionPixelSize" }.hook(
HookStage.AFTER,
{ isImmersiveCamera }
) { param ->
val id = param.arg<Int>(0)
if (id == getId("capri_viewfinder_default_corner_radius", "dimen") ||
id == getId("ngs_hova_nav_larger_camera_button_size", "dimen")) {
if (
id == getId("capri_viewfinder_default_corner_radius", "dimen") ||
id == getId("ngs_hova_nav_larger_camera_button_size", "dimen")
) {
param.setResult(0)
}
}
@@ -96,12 +266,17 @@ class UITweaks : Feature("UITweaks") {
if (event.view is FrameLayout) {
fun removeView() {
event.view.layoutParams = event.view.layoutParams?.apply {
width = 0; height = 0
width = 0
height = 0
} ?: return
}
val viewModelString = event.prevModel.toString()
val isMyStory by lazy { viewModelString.let { it.startsWith("StoryCarouselItemViewModel") && it.contains("storyId=") } }
val isMyStory by lazy {
viewModelString.let {
it.startsWith("StoryCarouselItemViewModel") && it.contains("storyId=")
}
}
if (hideStorySuggestions.contains("hide_my_stories") && isMyStory) {
removeView()
@@ -110,6 +285,10 @@ class UITweaks : Feature("UITweaks") {
}
}
context.event.subscribe(BindViewEvent::class, { disableSpotlight }) { event ->
findSpotlightHeaderTabsTarget(event.view)?.hideViewCompletely()
}
context.event.subscribe(AddViewEvent::class) { event ->
val viewId = event.view.id
val view = event.view
@@ -118,6 +297,15 @@ class UITweaks : Feature("UITweaks") {
hideStorySection(event)
}
findSpotlightNavTarget(event, spotlightNavIds, spotlightNavNames)?.takeIf { disableSpotlight }?.let { targetView ->
targetView.hideViewCompletely()
if (targetView !== view) {
view.hideViewCompletely()
}
event.canceled = true
return@subscribe
}
if (isImmersiveCamera) {
if (view.id == getId("edits_container", "id")) {
Hooker.hookObjectMethod(View::class.java, view, "layout", HookStage.BEFORE) {
@@ -134,7 +322,10 @@ class UITweaks : Feature("UITweaks") {
}
}
if (hiddenElements.contains("hide_billboard_prompt") && event.parent.javaClass.name.endsWith("BillboardFeedHeaderPromptComponent")) {
if (
hiddenElements.contains("hide_billboard_prompt") &&
event.parent.javaClass.name.endsWith("BillboardFeedHeaderPromptComponent")
) {
hideView(event.parent)
view.getValdiContext()?.componentContext?.get()?.dataBuilder {
val dismissFunction = get<Any>("_onDismiss") ?: return@subscribe
@@ -142,7 +333,11 @@ class UITweaks : Feature("UITweaks") {
}
}
if (event.parent.javaClass.name.endsWith("ConstraintLayout") && event.view is LinearLayout && hiddenElements.contains("hide_map_reactions")) {
if (
event.parent.javaClass.name.endsWith("ConstraintLayout") &&
event.view is LinearLayout &&
hiddenElements.contains("hide_map_reactions")
) {
val viewGroup = event.view as ViewGroup
val children = viewGroup.children()
@@ -154,7 +349,10 @@ class UITweaks : Feature("UITweaks") {
}
}
if (event.parent.javaClass.name.endsWith("PreviewBottomToolbarView") && hiddenElements.contains("hide_post_to_story_buttons")) {
if (
event.parent.javaClass.name.endsWith("PreviewBottomToolbarView") &&
hiddenElements.contains("hide_post_to_story_buttons")
) {
if (event.parent.childCount == 1) {
event.view.hideViewCompletely()
}
@@ -163,7 +361,8 @@ class UITweaks : Feature("UITweaks") {
if (viewId == getId("send_btn", "id") && hiddenElements.contains("hide_post_to_story_buttons")) {
// hide previous view
if (event.parent.childCount > 0) {
val lastChild = event.parent.getChildAt(event.parent.childCount - 1)?.takeIf { it is LinearLayout } ?: return@subscribe
val lastChild = event.parent.getChildAt(event.parent.childCount - 1)
?.takeIf { it is LinearLayout } ?: return@subscribe
context.log.verbose("Hiding post to story button")
lastChild.hideViewCompletely()
}
@@ -174,7 +373,11 @@ class UITweaks : Feature("UITweaks") {
if (hiddenElements.contains("hide_live_location_share_button")) {
chatInputBar?.onLayoutChange {
chatInputBar!!.children().lastOrNull { it.javaClass.name.endsWith("AppCompatImageButton") && runCatching { it.resources.getResourceName(it.id) }.getOrNull() == null }
chatInputBar!!.children()
.lastOrNull {
it.javaClass.name.endsWith("AppCompatImageButton") &&
runCatching { it.resources.getResourceName(it.id) }.getOrNull() == null
}
?.hideViewCompletely()
}
}

View File

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

View File

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

View File

@@ -1,14 +1,18 @@
package me.eternal.purrfectsnap.core.ui.menu.impl
import android.app.ActivityManager
import android.os.Process
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import me.eternal.purrfectsnap.common.Constants
import me.eternal.purrfectsnap.common.ui.OverlayType
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.util.ktx.getDrawable
import me.eternal.purrfectsnap.core.util.ktx.getStyledAttributes
import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress
class SettingsGearInjector : AbstractMenu() {
private val hovaHeaderAddFriendIconId by lazy {
@@ -47,6 +51,9 @@ class SettingsGearInjector : AbstractMenu() {
this@SettingsGearInjector.context.log.info("Gear icon clicked.", logTag)
this@SettingsGearInjector.context.bridgeClient.openOverlay(OverlayType.SETTINGS)
}
setOnLongClickListener {
this@SettingsGearInjector.handleChatHoldKillAction()
}
}
val layoutParams = FrameLayout.LayoutParams(
@@ -88,4 +95,25 @@ class SettingsGearInjector : AbstractMenu() {
}
}
}
private fun handleChatHoldKillAction(): Boolean {
val selectedActions = context.config.userInterface.chatHoldKillActions.get()
if (selectedActions.isEmpty()) return false
context.androidContext.vibrateLongPress()
context.mainActivity?.vibrateLongPress()
if (selectedActions.contains("kill_purrfectsnap")) {
runCatching {
val activityManager = context.androidContext.getSystemService(ActivityManager::class.java)
activityManager?.killBackgroundProcesses(Constants.MODULE_PACKAGE_NAME)
}
}
if (selectedActions.contains("kill_snapchat")) {
Process.killProcess(Process.myPid())
}
return true
}
}

View File

@@ -1,12 +1,16 @@
package me.eternal.purrfectsnap.core.ui.menu.impl
import android.app.ActivityManager
import android.os.Process
import android.view.View
import android.widget.FrameLayout
import me.eternal.purrfectsnap.common.Constants
import me.eternal.purrfectsnap.common.ui.OverlayType
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getId
import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress
class SettingsMenu : AbstractMenu() {
private val hovaHeaderSearchIconId by lazy {
@@ -22,8 +26,32 @@ class SettingsMenu : AbstractMenu() {
view.setOnClickListener {
context.bridgeClient.openOverlay(OverlayType.SETTINGS)
}
view.setOnLongClickListener {
handleChatHoldKillAction()
}
}
}
}
}
private fun handleChatHoldKillAction(): Boolean {
val selectedActions = context.config.userInterface.chatHoldKillActions.get()
if (selectedActions.isEmpty()) return false
context.androidContext.vibrateLongPress()
context.mainActivity?.vibrateLongPress()
if (selectedActions.contains("kill_purrfectsnap")) {
runCatching {
val activityManager = context.androidContext.getSystemService(ActivityManager::class.java)
activityManager?.killBackgroundProcesses(Constants.MODULE_PACKAGE_NAME)
}
}
if (selectedActions.contains("kill_snapchat")) {
Process.killProcess(Process.myPid())
}
return true
}
}

View File

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

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.6.8
APP_VERSION_CODE=324
APP_VERSION_NAME=1.6.9
APP_VERSION_CODE=325
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c

View File

@@ -27,7 +27,7 @@ detect_host_tag() {
HOST_LIB_SUBDIR="lib64"
;;
darwin*)
if [[ "$arch" == "arm64" ]]; then
if [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then
HOST_TAG="darwin-arm64"
else
HOST_TAG="darwin-x86_64"
@@ -47,23 +47,37 @@ detect_host_tag() {
ensure_ndk_for_host() {
local try_home="$1"
local bin_dir="$try_home/toolchains/llvm/prebuilt/$HOST_TAG/bin"
local lib_root="$try_home/toolchains/llvm/prebuilt/$HOST_TAG"
local lib_dir="$lib_root/$HOST_LIB_SUBDIR"
if [ ! -d "$lib_dir" ]; then
if [ -d "$lib_root/lib64" ]; then
lib_dir="$lib_root/lib64"
elif [ -d "$lib_root/lib" ]; then
lib_dir="$lib_root/lib"
local prebuilt_root="$try_home/toolchains/llvm/prebuilt"
local requested_tag="$HOST_TAG"
local -a candidate_tags=("$requested_tag")
if [[ "$requested_tag" == "darwin-arm64" ]]; then
candidate_tags+=("darwin-x86_64")
fi
local candidate_tag
for candidate_tag in "${candidate_tags[@]}"; do
local bin_dir="$prebuilt_root/$candidate_tag/bin"
local lib_root="$prebuilt_root/$candidate_tag"
local lib_dir="$lib_root/$HOST_LIB_SUBDIR"
if [ ! -d "$lib_dir" ]; then
if [ -d "$lib_root/lib64" ]; then
lib_dir="$lib_root/lib64"
elif [ -d "$lib_root/lib" ]; then
lib_dir="$lib_root/lib"
fi
fi
fi
if [ -d "$bin_dir" ] && [ -d "$lib_dir" ]; then
ANDROID_NDK_HOME="$try_home"
NDK_TOOLCHAIN_DIR="$bin_dir"
NDK_LIB_DIR="$lib_dir"
echo "$bin_dir"
return 0
fi
if [ -d "$bin_dir" ] && [ -d "$lib_dir" ]; then
HOST_TAG="$candidate_tag"
ANDROID_NDK_HOME="$try_home"
NDK_TOOLCHAIN_DIR="$bin_dir"
NDK_LIB_DIR="$lib_dir"
if [ "$candidate_tag" != "$requested_tag" ]; then
echo "Falling back to NDK host toolchain: $candidate_tag (requested $requested_tag)" >&2
fi
echo "$bin_dir"
return 0
fi
done
return 1
}
@@ -171,13 +185,24 @@ if [[ -z "$TOOLCHAIN" ]]; then
fi
fi
# OMVLL is required and only supported on Linux/macOS/WSL. Fail fast on plain Windows.
USE_OMVLL=true
# OMVLL can be disabled for environments where the pass plugin is unstable.
USE_OMVLL="${USE_OMVLL:-}"
IS_WSL=false
if grep -qi microsoft /proc/version 2>/dev/null; then
IS_WSL=true
fi
if [[ "$HOST_TAG" == windows-* && "$IS_WSL" != true ]]; then
# Default to disabling OMVLL on macOS CI where LLVM pass plugin loading is unstable.
if [ -z "$USE_OMVLL" ]; then
if [[ "$HOST_TAG" == darwin-* && "${CI:-}" == "true" ]]; then
USE_OMVLL=false
echo "Disabling OMVLL on macOS CI to avoid LLVM pass plugin crashes." >&2
else
USE_OMVLL=true
fi
fi
if [[ "$USE_OMVLL" == "true" && "$HOST_TAG" == windows-* && "$IS_WSL" != true ]]; then
echo "OMVLL requires a Linux/WSL environment. Please run the build via WSL (e.g., set BASH_PATH=C:\\Windows\\System32\\bash.exe)." >&2
exit 1
fi
@@ -247,17 +272,21 @@ if [[ "$HOST_TAG" == darwin-* ]]; then
fi
fi
ensure_omvll_bundle
append_rustflags CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS
append_rustflags CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_RUSTFLAGS
if [[ "$USE_OMVLL" == "true" ]]; then
ensure_omvll_bundle
append_rustflags CARGO_TARGET_AARCH64_LINUX_ANDROID_RUSTFLAGS
append_rustflags CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_RUSTFLAGS
if [ -z "${OMVLL_CONFIG:-}" ]; then
export OMVLL_CONFIG="$SCRIPT_DIR/omvll_config.py"
fi
if [ -z "${OMVLL_CONFIG:-}" ]; then
export OMVLL_CONFIG="$SCRIPT_DIR/omvll_config.py"
fi
if [ -z "${OMVLL_PYTHONPATH:-}" ] || [ ! -d "$OMVLL_PYTHONPATH" ]; then
echo "OMVLL_PYTHONPATH is not configured with a valid stdlib" >&2
exit 1
if [ -z "${OMVLL_PYTHONPATH:-}" ] || [ ! -d "$OMVLL_PYTHONPATH" ]; then
echo "OMVLL_PYTHONPATH is not configured with a valid stdlib" >&2
exit 1
fi
else
echo "OMVLL disabled for this build (USE_OMVLL=$USE_OMVLL)." >&2
fi
rustup target add --toolchain "$TOOLCHAIN" "$1"
@@ -309,6 +338,14 @@ case "$1" in
esac
cd "$RUST_DIR"
# macOS CI runners may inject DYLD override variables that break Rust/cargo
# processes with libc++abi symbol shim errors and bus error 10.
if [[ "$HOST_TAG" == darwin-* ]]; then
unset DYLD_INSERT_LIBRARIES
unset DYLD_LIBRARY_PATH
unset DYLD_FRAMEWORK_PATH
unset DYLD_ROOT_PATH
fi
rustup run "$TOOLCHAIN" cargo build --release --target "$1"

View File

@@ -49,15 +49,29 @@ require(ndkHome.exists()) { "Configured NDK directory $ndkHome does not exist" }
val osName = System.getProperty("os.name").lowercase(Locale.ROOT)
val osArch = System.getProperty("os.arch").lowercase(Locale.ROOT)
val hostTag = when {
val preferredHostTag = when {
osName.contains("windows") -> "windows-x86_64"
osName.contains("mac") && osArch.contains("arm") -> "darwin-arm64"
osName.contains("mac") -> "darwin-x86_64"
else -> "linux-x86_64"
}
val toolchainBin = File(ndkHome, "toolchains/llvm/prebuilt/$hostTag/bin")
require(toolchainBin.exists()) { "Unable to locate NDK toolchain under $toolchainBin" }
val prebuiltRoot = File(ndkHome, "toolchains/llvm/prebuilt")
require(prebuiltRoot.exists()) { "Unable to locate NDK prebuilts under $prebuiltRoot" }
val hostTagCandidates = mutableListOf(preferredHostTag)
if (preferredHostTag == "darwin-arm64") {
hostTagCandidates.add("darwin-x86_64")
}
if (preferredHostTag.startsWith("darwin")) {
prebuiltRoot.listFiles()
?.filter { it.isDirectory && it.name.startsWith("darwin-") }
?.forEach { hostTagCandidates.add(it.name) }
}
val hostTag = hostTagCandidates
.firstOrNull { tag -> File(prebuiltRoot, "$tag/bin").exists() }
?: error("Unable to locate NDK toolchain bin directory for $preferredHostTag under $prebuiltRoot")
val toolchainBin = File(prebuiltRoot, "$hostTag/bin")
val isWindowsHost = hostTag.startsWith("windows")
val clangSuffix = if (isWindowsHost) ".cmd" else ""

36
native/rust/Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -128,4 +128,7 @@ include(":core")
include(":valdi")
include(":app")
include(":mapper")
include(":manager")
include(":native")
project(":manager").projectDir = file("mapper")