Merge branch 'RSR' of https://github.com/RSR1337/PurrfectSnap into RSR
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (?, ?)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -2,27 +2,31 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteSweep
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar
|
||||
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs
|
||||
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
|
||||
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
|
||||
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
|
||||
import me.eternal.purrfectsnap.ui.util.Motion
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -37,6 +41,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
var logReader by remember { mutableStateOf<me.eternal.purrfectsnap.LogReader?>(null) }
|
||||
val visibleLogs = remember { mutableStateListOf<me.eternal.purrfectsnap.LogLine>() }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
var showFilterDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun refreshLogs() {
|
||||
isRefreshing = true
|
||||
@@ -69,6 +74,67 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogFilterDialog() {
|
||||
Dialog(onDismissRequest = { showFilterDialog = false }) {
|
||||
PurrfectOverlayTheme {
|
||||
PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomeLogs.LogCategory.entries.forEach { category ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
enabledCategories.keys.forEach { enabledCategories[it] = false }
|
||||
enabledCategories[category] = true
|
||||
refreshLogs()
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = enabledCategories[category] == true,
|
||||
onCheckedChange = { checked ->
|
||||
enabledCategories[category] = checked
|
||||
refreshLogs()
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.4f),
|
||||
checkmarkColor = Color.White
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = translation[category.translationKey] ?: category.name,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = { showFilterDialog = false },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
|
||||
) {
|
||||
Text(translation["filter_logs_done_button"] ?: "Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showFilterDialog) {
|
||||
LogFilterDialog()
|
||||
}
|
||||
|
||||
LaunchedEffect(externalRefreshTick.value) {
|
||||
if (externalRefreshTick.value > 0) {
|
||||
refreshLogs()
|
||||
@@ -132,6 +198,9 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showFilterDialog = true }) {
|
||||
Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary)
|
||||
}
|
||||
IconButton(onClick = { refreshLogs() }) {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White)
|
||||
}
|
||||
|
||||
@@ -1121,6 +1121,8 @@ object LegacyTheme : ThemeContract {
|
||||
val visibleLogs = remember { mutableStateListOf<LogLine>() }
|
||||
val mainExecutor = remember { context.androidContext.mainExecutor }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
var showFilterDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun refreshLogs() {
|
||||
coroutineScope.launch {
|
||||
val readerResult = withContext(Dispatchers.IO) {
|
||||
@@ -1154,6 +1156,71 @@ object LegacyTheme : ThemeContract {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogFilterDialog() {
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = { showFilterDialog = false }) {
|
||||
me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme {
|
||||
me.eternal.purrfectsnap.core.ui.PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomeLogs.LogCategory.entries.forEach { category ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
// Solo Focus Logic: Tap the name to filter only this category
|
||||
enabledCategories.keys.forEach { enabledCategories[it] = false }
|
||||
enabledCategories[category] = true
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Checkbox(
|
||||
checked = enabledCategories[category] == true,
|
||||
onCheckedChange = { checked ->
|
||||
enabledCategories[category] = checked
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.4f),
|
||||
checkmarkColor = Color.White
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = translation[category.translationKey] ?: category.name,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = { showFilterDialog = false },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
|
||||
) {
|
||||
Text(translation["filter_logs_done_button"] ?: "Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showFilterDialog) {
|
||||
LogFilterDialog()
|
||||
}
|
||||
|
||||
LaunchedEffect(externalRefreshTick.intValue) {
|
||||
if (externalRefreshTick.intValue > 0) {
|
||||
isRefreshing = true
|
||||
@@ -1181,6 +1248,7 @@ object LegacyTheme : ThemeContract {
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
},
|
||||
onFilter = { showFilterDialog = true },
|
||||
onExport = { exportLogs() },
|
||||
onClear = { clearLogsAndReload() }
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,9 +9,9 @@ class DownloaderConfig : ConfigContainer() {
|
||||
val threads = integer("threads", 4) // Bump Default Value to 4 Tested on Pixel 5 (Qualcomm Snapdragon 765G) Had no lag
|
||||
val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") {
|
||||
addFlags(ConfigFlag.NO_TRANSLATE)
|
||||
}
|
||||
val constantRateFactor = integer("constant_rate_factor", 30)
|
||||
val videoBitrate = integer("video_bitrate", 5000)
|
||||
}.apply { set("veryfast") }
|
||||
val constantRateFactor = integer("constant_rate_factor", 22)
|
||||
val videoBitrate = integer("video_bitrate", 8000)
|
||||
val audioBitrate = integer("audio_bitrate", 128)
|
||||
val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) }
|
||||
val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) }
|
||||
|
||||
@@ -35,6 +35,7 @@ class Experimental : ConfigContainer() {
|
||||
class NativeHooks : ConfigContainer() {
|
||||
val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() }
|
||||
val disableBitmoji = boolean("disable_bitmoji")
|
||||
val debugFontRedirect = boolean("debug_font_redirect") { addFlags(ConfigFlag.HIDDEN) }
|
||||
val customEmojiFont = string("custom_emoji_font") {
|
||||
requireRestart()
|
||||
addFlags(ConfigFlag.USER_IMPORT)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,7 @@ open class ScriptRuntime(
|
||||
|
||||
private val modules = mutableMapOf<String, JSModule>()
|
||||
|
||||
fun eachModule(f: JSModule.() -> Unit) {
|
||||
open fun eachModule(f: JSModule.() -> Unit) {
|
||||
modules.values.forEach { module ->
|
||||
runCatching {
|
||||
module.f()
|
||||
|
||||
@@ -59,12 +59,14 @@ fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFi
|
||||
val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1])
|
||||
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
copyTo(fos)
|
||||
} finally {
|
||||
close()
|
||||
fos.flush()
|
||||
fos.close()
|
||||
runCatching {
|
||||
try {
|
||||
copyTo(fos)
|
||||
} finally {
|
||||
close()
|
||||
fos.flush()
|
||||
fos.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,10 +164,10 @@ class ModContext(
|
||||
disableMetrics = config.global.disableMetrics.get(),
|
||||
valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q,
|
||||
customEmojiFontPath = getCustomEmojiFontPath(this)
|
||||
)
|
||||
)
|
||||
}
|
||||
customEmojiFontPath = getCustomEmojiFontPath(this),
|
||||
debugFontRedirect = config.experimental.nativeHooks.debugFontRedirect.get()
|
||||
)
|
||||
) }
|
||||
|
||||
fun getConfigLocale(): String {
|
||||
return _config.locale
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.eternal.purrfectsnap.core
|
||||
|
||||
import me.eternal.purrfectsnap.common.scripting.JSModule
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
@@ -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() },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
@@ -18,23 +17,22 @@ import androidx.core.content.edit
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import me.eternal.purrfectsnap.bridge.AutoOpenInterface
|
||||
import me.eternal.purrfectsnap.common.BuildConfig
|
||||
import me.eternal.purrfectsnap.common.config.PropertyValue
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.MessageState
|
||||
import me.eternal.purrfectsnap.common.data.MessageUpdate
|
||||
import me.eternal.purrfectsnap.common.data.MessagingRuleType
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.BuildMessageEvent
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.Message
|
||||
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
|
||||
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
|
||||
import me.eternal.purrfectsnap.core.features.impl.tweaks.PerformanceMode
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
|
||||
import java.util.*
|
||||
import java.util.Objects
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -42,400 +40,326 @@ import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* AutoOpenSnaps: High-performance engine with real-time diagnostics.
|
||||
* Optimized for 20+ snaps/s with accurate stats and background resilience.
|
||||
*/
|
||||
class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) {
|
||||
companion object {
|
||||
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_PAUSE_RESUME"
|
||||
const val ACTION_CLEAR_QUEUE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_CLEAR_QUEUE"
|
||||
const val ACTION_STOP_ENGINE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_STOP_ENGINE"
|
||||
|
||||
private const val STATUS_NOTIFICATION_ID = 54321
|
||||
private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN"
|
||||
private const val PREF_TOTAL_OPENED = "auto_open_total_opened"
|
||||
private const val PREF_SESSION_START = "auto_open_session_start"
|
||||
private const val PREF_SAVED_QUEUE = "auto_open_saved_queue"
|
||||
|
||||
private const val LAZY_SAVE_INTERVAL_MS = 600_000L
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val isPaused = AtomicBoolean(false)
|
||||
private val totalProcessed = AtomicInteger(0)
|
||||
private val sessionProcessed = AtomicInteger(0)
|
||||
private val engineActive = AtomicBoolean(true)
|
||||
private val totalProcessed = AtomicInteger(0)
|
||||
private val sessionProcessed = AtomicInteger(0)
|
||||
private val sessionStartTime = AtomicLong(System.currentTimeMillis())
|
||||
private val totalPausedDuration = AtomicLong(0)
|
||||
private var lastPausedAt = AtomicLong(0)
|
||||
private val averageProcessingTime = AtomicLong(800)
|
||||
private val hasBeenActive = AtomicBoolean(false)
|
||||
private val isScreenOn = AtomicBoolean(true)
|
||||
private val lastSnapProcessedAt = AtomicLong(0)
|
||||
|
||||
private val snapChannel = Channel<SnapQueueItem>(Channel.UNLIMITED)
|
||||
private val openedSnapsIds = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val queuedSnaps = LinkedList<SnapQueueItem>()
|
||||
private var engineJob: Job? = null
|
||||
private val engineDispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
|
||||
private val snapQueue = MutableSharedFlow<Long>(extraBufferCapacity = 100)
|
||||
private val openedSnaps = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val queuedSnaps = mutableListOf<SnapQueueItem>()
|
||||
private val deadLetterQueue = mutableListOf<SnapQueueItem>()
|
||||
private val autoOpenConfig by lazy { this@AutoOpenSnaps.context.config.messaging.autoOpenSnaps }
|
||||
private val notificationManager by lazy { this@AutoOpenSnaps.context.androidContext.getSystemService(NotificationManager::class.java) }
|
||||
private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) }
|
||||
private val messaging by lazy { this@AutoOpenSnaps.context.feature(Messaging::class) }
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
private val metadataCache = Collections.synchronizedMap(object : LinkedHashMap<String, String>() {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, String>?): Boolean = size > 500
|
||||
})
|
||||
|
||||
private val config by lazy { context.config.messaging.autoOpenSnaps }
|
||||
private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java) }
|
||||
private val prefs by lazy { context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) }
|
||||
|
||||
private var lastConversationId: String? = null
|
||||
private var currentStatusText = "Monitoring..."
|
||||
private var currentSpeedText = "Full Speed"
|
||||
private var isCurrentlyWaiting = false
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wakeLockCooldownJob: Job? = null
|
||||
private var lastQueueActivity = System.currentTimeMillis()
|
||||
|
||||
private val lastNotificationUpdate = AtomicLong(0)
|
||||
private var lastNotificationUpdate = 0L
|
||||
private val notificationUpdateDelay = 1000L
|
||||
private val pendingNotificationUpdate = AtomicBoolean(false)
|
||||
private val snapTimestamps = LinkedList<Long>()
|
||||
private var lastConversationId: String? = null
|
||||
|
||||
private val isSaving = AtomicBoolean(false)
|
||||
private val needsSaving = AtomicBoolean(false)
|
||||
private var isThermalThrottled = false
|
||||
private var lastThermalThrottleAt = 0L
|
||||
|
||||
private fun cancelStatusNotification() {
|
||||
runCatching { notificationManager.cancel(STATUS_NOTIFICATION_ID) }
|
||||
}
|
||||
|
||||
data class SnapQueueItem(
|
||||
val conversationId: String,
|
||||
val messageId: Long,
|
||||
val serverMessageId: Long?,
|
||||
val senderId: String,
|
||||
var senderName: String = "Pending...",
|
||||
var conversationType: String = "Processing",
|
||||
val contentType: String,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
private val autoOpenInterface = object : AutoOpenInterface.Stub() {
|
||||
override fun getProcessedCount(): Int = sessionProcessed.get()
|
||||
override fun getQueueItems(): List<String> = synchronized(queuedSnaps) { queuedSnaps.map { gson.toJson(it) } }
|
||||
override fun reset() { clearInternalState() }
|
||||
}
|
||||
|
||||
private fun clearInternalState() {
|
||||
sessionProcessed.set(0)
|
||||
totalProcessed.set(0)
|
||||
totalPausedDuration.set(0)
|
||||
lastPausedAt.set(0)
|
||||
sessionStartTime.set(System.currentTimeMillis())
|
||||
synchronized(queuedSnaps) { queuedSnaps.clear() }
|
||||
synchronized(deadLetterQueue) { deadLetterQueue.clear() }
|
||||
openedSnaps.clear()
|
||||
|
||||
prefs.edit()
|
||||
.putLong(PREF_SESSION_START, System.currentTimeMillis())
|
||||
.remove(PREF_SAVED_QUEUE)
|
||||
.remove(PREF_TOTAL_OPENED)
|
||||
.apply()
|
||||
|
||||
updateStatusNotification(force = true)
|
||||
}
|
||||
|
||||
fun getSnapMetadata(clientMessageId: Long): SnapQueueItem? = synchronized(queuedSnaps) { queuedSnaps.find { it.messageId == clientMessageId } }
|
||||
|
||||
fun getInterface(): AutoOpenInterface = autoOpenInterface
|
||||
|
||||
private val actionReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_PAUSE_RESUME -> {
|
||||
val paused = !isPaused.get()
|
||||
isPaused.set(paused)
|
||||
if (paused) lastPausedAt.set(System.currentTimeMillis())
|
||||
else {
|
||||
if (lastPausedAt.get() > 0) totalPausedDuration.addAndGet(System.currentTimeMillis() - lastPausedAt.get())
|
||||
snapQueue.tryEmit(System.currentTimeMillis())
|
||||
}
|
||||
updateStatusNotification(force = true)
|
||||
}
|
||||
ACTION_CLEAR_QUEUE -> clearInternalState()
|
||||
Intent.ACTION_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) }
|
||||
Intent.ACTION_SCREEN_OFF -> isScreenOn.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
val messaging = context.feature(Messaging::class)
|
||||
restorePersistence()
|
||||
hasBeenActive.set(config.globalState == true)
|
||||
|
||||
if (config.allowRunningInBackground.get()) {
|
||||
acquireWakeLock()
|
||||
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
|
||||
hook("appStateChanged", HookStage.BEFORE) { param ->
|
||||
if (config.allowRunningInBackground.get()) {
|
||||
val state = param.arg<Any>(0).toString()
|
||||
if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null)
|
||||
}
|
||||
}
|
||||
hookConstructor(HookStage.AFTER) { param ->
|
||||
methods.firstOrNull { it.name == "appStateChanged" }?.let { method ->
|
||||
val enumClass = method.parameterTypes[0]
|
||||
val activeState = enumClass.enumConstants?.firstOrNull { it.toString() == "ACTIVE" || it.toString() == "FOREGROUND" }
|
||||
if (activeState != null) method.invoke(param.thisObject<Any>(), activeState)
|
||||
}
|
||||
}
|
||||
}
|
||||
findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply {
|
||||
hook("onAppForegrounded", HookStage.BEFORE) { param -> if (config.allowRunningInBackground.get()) param.setResult(null) }
|
||||
hook("onAppBackgrounded", HookStage.BEFORE) { param -> if (config.allowRunningInBackground.get()) param.setResult(null) }
|
||||
}
|
||||
}
|
||||
|
||||
createNotificationChannels()
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(Intent.ACTION_BATTERY_CHANGED); addAction(Intent.ACTION_SCREEN_ON); addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
|
||||
val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
if (intent?.action == Intent.ACTION_BATTERY_CHANGED && config.thermalProtection.get()) {
|
||||
val temp = intent.getIntExtra("temperature", 0) / 10f
|
||||
if (temp >= 40f && !isThermalThrottled) {
|
||||
isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis()
|
||||
} else if (isThermalThrottled && temp <= 36f && System.currentTimeMillis() - lastThermalThrottleAt > 600000) {
|
||||
isThermalThrottled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
context.androidContext.registerReceiver(batteryReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
context.androidContext.registerReceiver(actionReceiver, filter)
|
||||
context.androidContext.registerReceiver(batteryReceiver, filter)
|
||||
}
|
||||
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
|
||||
// Watchdog Loop
|
||||
context.coroutineScope.launch(Dispatchers.Default) {
|
||||
while (isActive) {
|
||||
if (config.globalState != true) { shutdownFeature(); break }
|
||||
val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
|
||||
if (remainingCount > 0) {
|
||||
lastQueueActivity = System.currentTimeMillis(); acquireWakeLock()
|
||||
if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
} else {
|
||||
if (!isPaused.get() && System.currentTimeMillis() - lastQueueActivity > 300000) {
|
||||
val revived = synchronized(deadLetterQueue) { if (deadLetterQueue.isNotEmpty()) deadLetterQueue.removeAt(0) else null }
|
||||
if (revived != null) { synchronized(queuedSnaps) { queuedSnaps.add(revived) }; snapQueue.tryEmit(System.currentTimeMillis()) }
|
||||
}
|
||||
if (System.currentTimeMillis() - lastQueueActivity > 300000) {
|
||||
startWakeLockCooldown()
|
||||
}
|
||||
}
|
||||
updateStatusNotification()
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
|
||||
// Processing Loop
|
||||
context.coroutineScope.launch(Dispatchers.Default) {
|
||||
snapQueue.collect {
|
||||
if (isPaused.get() || config.globalState != true) return@collect
|
||||
while (isActive && config.globalState == true) {
|
||||
val item = synchronized(queuedSnaps) { if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null } ?: break
|
||||
|
||||
var resourceWaiting = true
|
||||
while (resourceWaiting) {
|
||||
if (config.globalState != true || isPaused.get()) break
|
||||
val isWifi = isWifiConnected()
|
||||
val isIdle = isDeviceIdle()
|
||||
val onlyIdle = config.onlyWhenIdle.get()
|
||||
val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false
|
||||
|
||||
when {
|
||||
config.onlyOnWifi.get() && !isWifi -> {
|
||||
currentStatusText = "Waiting for WiFi..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000)
|
||||
}
|
||||
onlyIdle && !isIdle && !inSleepWindow -> {
|
||||
currentStatusText = "Waiting for idle..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000)
|
||||
}
|
||||
else -> {
|
||||
resourceWaiting = false;
|
||||
val thermalActive = config.thermalProtection.get() && isThermalThrottled
|
||||
currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed"
|
||||
}
|
||||
}
|
||||
if (resourceWaiting) updateStatusNotification()
|
||||
}
|
||||
|
||||
if (isPaused.get() || config.globalState != true) { synchronized(queuedSnaps) { queuedSnaps.add(0, item) }; continue }
|
||||
isCurrentlyWaiting = false
|
||||
|
||||
// TIMING: 40ms switch
|
||||
if (lastConversationId != null && lastConversationId != item.conversationId) { delay(40) }
|
||||
lastConversationId = item.conversationId
|
||||
currentStatusText = "Active"; updateStatusNotification()
|
||||
|
||||
var success = false
|
||||
val startTime = System.currentTimeMillis()
|
||||
var currentRetryDelay = config.retryDelay.get().toLong()
|
||||
|
||||
for (i in 0 until config.retryAttempts.get()) {
|
||||
if (isPaused.get() || config.globalState != true) break
|
||||
|
||||
// Bridge Handshake
|
||||
if (messaging.conversationManager == null) {
|
||||
runCatching { context.messagingBridge.triggerSessionStart() }
|
||||
var waitTime = 0
|
||||
while (messaging.conversationManager == null && waitTime < 2000) { delay(100); waitTime += 100 }
|
||||
}
|
||||
|
||||
success = performOpen(messaging, item)
|
||||
if (success) {
|
||||
sessionProcessed.incrementAndGet()
|
||||
totalProcessed.incrementAndGet()
|
||||
synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 100) snapTimestamps.removeFirst() }
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong())
|
||||
delay(5)
|
||||
break
|
||||
}
|
||||
if (i < config.retryAttempts.get() - 1) {
|
||||
currentStatusText = "Retrying..."; updateStatusNotification(); delay(currentRetryDelay); currentRetryDelay *= 2
|
||||
}
|
||||
}
|
||||
|
||||
if (!success && !isPaused.get()) {
|
||||
currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification()
|
||||
synchronized(openedSnaps) { openedSnaps.remove(item.messageId) }
|
||||
synchronized(deadLetterQueue) { if (deadLetterQueue.size < 100) deadLetterQueue.add(item) else { deadLetterQueue.removeAt(0); deadLetterQueue.add(item) } }
|
||||
}
|
||||
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
|
||||
currentStatusText = "Monitoring..."; updateStatusNotification()
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global Detector
|
||||
context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
|
||||
// GLOBAL SILENCE GUARD
|
||||
if (config.globalState != true) return@subscribe
|
||||
|
||||
val message = event.message
|
||||
if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == context.database.myUserId) return@subscribe
|
||||
|
||||
val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
|
||||
val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe
|
||||
val serverMsgId = message.orderKey
|
||||
val contentType = message.messageContent?.contentType
|
||||
if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe
|
||||
|
||||
if (config.globalState != true) return@subscribe
|
||||
|
||||
// Whitelist Resilience: Robust rule check
|
||||
val ruleState = context.config.rules.getRuleState(ruleType)
|
||||
val isWhitelisted = getState(conversationId)
|
||||
val canProcess = if (ruleState == me.eternal.purrfectsnap.common.data.RuleState.BLACKLIST) !isWhitelisted else isWhitelisted
|
||||
|
||||
if (!canProcess) return@subscribe
|
||||
|
||||
acquireWakeLock()
|
||||
synchronized(openedSnaps) {
|
||||
if (openedSnaps.contains(clientMessageId)) return@subscribe
|
||||
openedSnaps.add(clientMessageId)
|
||||
if (openedSnaps.size > 5000) openedSnaps.clear()
|
||||
}
|
||||
|
||||
val senderId = message.senderId?.toString() ?: "unknown"
|
||||
val item = SnapQueueItem(conversationId, clientMessageId, serverMsgId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
|
||||
|
||||
synchronized(queuedSnaps) {
|
||||
if (queuedSnaps.size >= config.queueSize.get()) queuedSnaps.removeFirstOrNull()
|
||||
queuedSnaps.add(item)
|
||||
}
|
||||
|
||||
if (context.config.messaging.preFetchSnaps.get()) {
|
||||
runCatching { messaging.conversationManager?.fetchMessage(conversationId, clientMessageId, {}, {}) }
|
||||
}
|
||||
|
||||
if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis())
|
||||
updateStatusNotification()
|
||||
triggerLazySave()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performOpen(messaging: Messaging, item: SnapQueueItem): Boolean = withContext(Dispatchers.IO) {
|
||||
val manager = messaging.conversationManager ?: return@withContext false
|
||||
suspendCancellableCoroutine<Boolean> { cont ->
|
||||
runCatching {
|
||||
manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result ->
|
||||
if (result == null || result == "DUPLICATEREQUEST") {
|
||||
cont.resume(true)
|
||||
} else if (item.serverMessageId != null) {
|
||||
manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult ->
|
||||
cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST")
|
||||
}
|
||||
} else {
|
||||
cont.resume(false)
|
||||
}
|
||||
}
|
||||
}.onFailure { cont.resume(false) }
|
||||
}
|
||||
}
|
||||
private fun logInfo(msg: String) = this@AutoOpenSnaps.context.log.info("[AutoOpenEngine] $msg")
|
||||
private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg", e) else this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg")
|
||||
|
||||
private fun getSnapsPerSecond(): Double {
|
||||
val now = System.currentTimeMillis(); val window = 5000L
|
||||
synchronized(snapTimestamps) {
|
||||
snapTimestamps.removeIf { now - it > window }; return (snapTimestamps.size.toDouble() / (window / 1000.0))
|
||||
snapTimestamps.removeIf { now - it > window }
|
||||
// Smoother calculation for high-frequency bursts
|
||||
return if (snapTimestamps.isEmpty()) 0.0 else (snapTimestamps.size.toDouble() / (window / 1000.0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(m: Long): String {
|
||||
val s = (m / 1000) % 60; val min = (m / 60000) % 60; val h = m / 3600000
|
||||
return when { h > 0 -> "${h}h ${min}m"; min > 0 -> "${min}m ${s}s"; else -> "${s}s" }
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
restorePersistence()
|
||||
createNotificationChannels()
|
||||
|
||||
// NATIVE HOOKS: Ensuring Snapchat never sees the app as "In Background"
|
||||
if ((autoOpenConfig.allowRunningInBackground as PropertyValue<Boolean>).get()) {
|
||||
runCatching {
|
||||
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
|
||||
hook("appStateChanged", HookStage.BEFORE) { param ->
|
||||
val state = param.arg<Any>(0).toString()
|
||||
if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null)
|
||||
}
|
||||
}
|
||||
findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply {
|
||||
hook("onAppForegrounded", HookStage.BEFORE) { param -> param.setResult(null) }
|
||||
hook("onAppBackgrounded", HookStage.BEFORE) { param -> param.setResult(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupReceivers()
|
||||
startEngineWorker()
|
||||
setupDetector()
|
||||
}
|
||||
|
||||
private fun startEngineWorker() {
|
||||
engineJob = this@AutoOpenSnaps.context.coroutineScope.launch(engineDispatcher) {
|
||||
while (engineActive.get()) {
|
||||
val item = try { snapChannel.receive() } catch (e: Exception) { break }
|
||||
|
||||
while (isPaused.get() && engineActive.get()) {
|
||||
currentStatusText = "Paused"; updateStatusNotification(); delay(500)
|
||||
}
|
||||
if (!engineActive.get()) break
|
||||
|
||||
updateStatusNotification()
|
||||
if (!validateEnvironmentalConstraints()) {
|
||||
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
|
||||
continue
|
||||
}
|
||||
|
||||
// SPEED OPTIMIZATION: Instant switch (40ms) when stealth is off
|
||||
val isSafe = (autoOpenConfig.safeProcessing as PropertyValue<Boolean>).get()
|
||||
if (lastConversationId != null && lastConversationId != item.conversationId) {
|
||||
delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue<Int>).get().toLong() else 40L)
|
||||
}
|
||||
lastConversationId = item.conversationId
|
||||
|
||||
processSnapItem(item)
|
||||
lastSnapProcessedAt.set(System.currentTimeMillis())
|
||||
|
||||
// HIGH SPEED: 10ms floor for 20+ snaps/s
|
||||
val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue<Int>).get().toLong()
|
||||
if (isSafe) {
|
||||
delay(Random.nextLong(baseDelay, baseDelay + 200))
|
||||
} else {
|
||||
delay(baseDelay.coerceAtMost(10))
|
||||
}
|
||||
|
||||
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
|
||||
currentStatusText = "Monitoring..."
|
||||
updateStatusNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processSnapItem(item: SnapQueueItem) {
|
||||
currentStatusText = "Active"; updateStatusNotification()
|
||||
var success = false
|
||||
val startTime = System.currentTimeMillis()
|
||||
for (i in 0 until (autoOpenConfig.retryAttempts as PropertyValue<Int>).get()) {
|
||||
if (isPaused.get() || !engineActive.get() || autoOpenConfig.globalState == false) break
|
||||
|
||||
if (messaging.conversationManager == null) {
|
||||
runCatching { this@AutoOpenSnaps.context.messagingBridge.triggerSessionStart() }
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
success = withContext(Dispatchers.IO) { performOpen(item) }
|
||||
if (success) {
|
||||
// IMPORTANT: Item only removed after successful processing to ensure Stats sync
|
||||
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
|
||||
sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp()
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong())
|
||||
triggerLazySave(); break
|
||||
}
|
||||
delay((autoOpenConfig.retryDelay as PropertyValue<Int>).get().toLong())
|
||||
}
|
||||
if (!success && !isPaused.get() && engineActive.get()) {
|
||||
logError("Engine failed to open Snap: ${item.messageId}")
|
||||
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
|
||||
currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification()
|
||||
openedSnapsIds.remove(item.messageId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performOpen(item: SnapQueueItem): Boolean {
|
||||
val manager = messaging.conversationManager ?: return false
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
runCatching {
|
||||
manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result ->
|
||||
if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) }
|
||||
else if (item.serverMessageId != 0L) {
|
||||
manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult ->
|
||||
cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST")
|
||||
}
|
||||
} else { cont.resume(false) }
|
||||
}
|
||||
}.onFailure { logError("Bridge Error", it); cont.resume(false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun validateEnvironmentalConstraints(): Boolean {
|
||||
while (engineActive.get()) {
|
||||
if (autoOpenConfig.globalState == false || isPaused.get()) return false
|
||||
val isWifi = isWifiConnected()
|
||||
val isIdle = isDeviceIdle()
|
||||
val onlyIdle = (autoOpenConfig.onlyWhenIdle as PropertyValue<Boolean>).get()
|
||||
val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false
|
||||
|
||||
val wifiStop = (autoOpenConfig.onlyOnWifi as PropertyValue<Boolean>).get() && !isWifi
|
||||
val idleStop = onlyIdle && !isIdle && !inSleepWindow
|
||||
|
||||
when {
|
||||
wifiStop -> { currentStatusText = "Waiting for WiFi..."; delay(5000) }
|
||||
idleStop -> { currentStatusText = "Waiting for Idle..."; delay(5000) }
|
||||
else -> {
|
||||
val thermalActive = (autoOpenConfig.thermalProtection as PropertyValue<Boolean>).get() && isThermalThrottled
|
||||
currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed"
|
||||
return true
|
||||
}
|
||||
}
|
||||
updateStatusNotification()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun setupDetector() {
|
||||
this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
|
||||
if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe
|
||||
val message = event.message
|
||||
if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe
|
||||
val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe
|
||||
val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe
|
||||
val serverMessageId = message.orderKey ?: 0L
|
||||
|
||||
val contentType = message.messageContent?.contentType
|
||||
if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe
|
||||
if (!canUseRule(conversationId)) return@subscribe
|
||||
if (openedSnapsIds.contains(clientMessageId)) return@subscribe
|
||||
openedSnapsIds.add(clientMessageId)
|
||||
|
||||
val senderId = message.senderId?.toString() ?: "unknown"
|
||||
val item = SnapQueueItem(conversationId, clientMessageId, serverMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType))
|
||||
|
||||
synchronized(queuedSnaps) { queuedSnaps.add(item) }
|
||||
snapChannel.trySend(item)
|
||||
|
||||
acquireWakeLock(); updateStatusNotification(); triggerLazySave()
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerLazySave() {
|
||||
needsSaving.set(true)
|
||||
if (isSaving.compareAndSet(false, true)) {
|
||||
this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.IO) {
|
||||
while (needsSaving.get() && engineActive.get()) {
|
||||
needsSaving.set(false); saveQueueToDisk(); delay(LAZY_SAVE_INTERVAL_MS)
|
||||
}
|
||||
isSaving.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveQueueToDisk() {
|
||||
prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) }
|
||||
}
|
||||
|
||||
private fun restorePersistence() {
|
||||
val savedStartTime = prefs.getLong(PREF_SESSION_START, 0)
|
||||
if (System.currentTimeMillis() - savedStartTime > 21600000) return
|
||||
totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime)
|
||||
}
|
||||
|
||||
private fun isWifiConnected(): Boolean {
|
||||
val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
return cm.getNetworkCapabilities(cm.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
}
|
||||
|
||||
private fun isDeviceIdle(): Boolean = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode
|
||||
private fun isInsideSleepWindow(): Boolean {
|
||||
val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
|
||||
return hour >= 23 || hour <= 6
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock?.isHeld == true) return
|
||||
wakeLock = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { acquire(8 * 60 * 60 * 1000L) }
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() { if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null }
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(NotificationChannel("auto_open_status", "Auto-Open Status", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateStatusNotification(force: Boolean = false) {
|
||||
val currentTime = System.currentTimeMillis(); val lastUpdate = lastNotificationUpdate.get()
|
||||
val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
if (!isScreenOn.get() && !force) return
|
||||
if (!force && (currentTime - lastUpdate) < notificationUpdateDelay) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) {
|
||||
if (pendingNotificationUpdate.compareAndSet(false, true)) {
|
||||
context.coroutineScope.launch { delay(notificationUpdateDelay - (currentTime - lastUpdate)); pendingNotificationUpdate.set(false); updateStatusNotificationInternal() }
|
||||
this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() }
|
||||
}
|
||||
return
|
||||
}
|
||||
lastNotificationUpdate.set(currentTime); updateStatusNotificationInternal()
|
||||
updateStatusNotificationInternal()
|
||||
}
|
||||
|
||||
private var lastNotificationStateHash: Int = 0
|
||||
|
||||
private fun updateStatusNotificationInternal() {
|
||||
if (!engineActive.get()) return
|
||||
val processed = sessionProcessed.get()
|
||||
val total = totalProcessed.get()
|
||||
val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
|
||||
|
||||
val currentStateHash = Objects.hash(processed, total, remaining, currentStatusText, isPaused.get())
|
||||
if (currentStateHash == lastNotificationStateHash && remaining == 0) return
|
||||
lastNotificationStateHash = currentStateHash
|
||||
|
||||
if (total <= 0 && remaining <= 0 && processed <= 0) return
|
||||
|
||||
val isWorking = remaining > 0
|
||||
val isCompact = config.compactNotification.get() == true
|
||||
val speed = if (isWorking) getSnapsPerSecond() else 0.0
|
||||
|
||||
lastNotificationUpdate = System.currentTimeMillis(); pendingNotificationUpdate.set(false)
|
||||
|
||||
val sessionTotal = processed + remaining
|
||||
val speed = getSnapsPerSecond()
|
||||
val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0
|
||||
val eta = if (isWorking && !isCurrentlyWaiting && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..."
|
||||
|
||||
val builder = Notification.Builder(context.androidContext, "auto_open_snaps")
|
||||
.setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play)
|
||||
.setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY).setGroupSummary(false)
|
||||
val eta = if (isWorking && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..."
|
||||
|
||||
val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status")
|
||||
.setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY)
|
||||
|
||||
// ICON LOGIC: Pause, Monitoring (Sync), or Active (Play)
|
||||
val iconRes = when {
|
||||
isPaused.get() -> android.R.drawable.ic_media_pause
|
||||
!isWorking -> android.R.drawable.ic_popup_sync
|
||||
else -> android.R.drawable.ic_media_play
|
||||
}
|
||||
builder.setSmallIcon(iconRes)
|
||||
builder.setContentTitle("Auto-Open: $currentStatusText")
|
||||
|
||||
val isCompact = (autoOpenConfig.compactNotification as PropertyValue<Boolean>).get()
|
||||
if (isWorking) {
|
||||
builder.setContentText("Opened: $processed │ Queue: $remaining")
|
||||
builder.setSubText("$progressPercent% • Ends in: ${eta ?: "..."}")
|
||||
builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)")
|
||||
builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta")
|
||||
builder.setProgress(sessionTotal, processed, false)
|
||||
} else {
|
||||
builder.setContentText("$processed Opened Today │ $total Total")
|
||||
@@ -444,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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.eternal.purrfectsnap.core.scripting
|
||||
|
||||
import me.eternal.purrfectsnap.common.scripting.JSModule
|
||||
import me.eternal.purrfectsnap.bridge.scripting.AutoReloadListener
|
||||
import me.eternal.purrfectsnap.common.logger.AbstractLogger
|
||||
import me.eternal.purrfectsnap.common.scripting.ScriptRuntime
|
||||
@@ -7,6 +8,11 @@ import me.eternal.purrfectsnap.common.scripting.bindings.BindingSide
|
||||
import me.eternal.purrfectsnap.core.ModContext
|
||||
import me.eternal.purrfectsnap.core.scripting.impl.*
|
||||
|
||||
/**
|
||||
* Core-side implementation of the [ScriptRuntime].
|
||||
* Manages script lifecycle synchronized with the JNI bridge connection state
|
||||
* to prevent race conditions during early-init hooks.
|
||||
*/
|
||||
class CoreScriptRuntime(
|
||||
private val modContext: ModContext,
|
||||
logger: AbstractLogger,
|
||||
@@ -15,9 +21,18 @@ class CoreScriptRuntime(
|
||||
androidContext = modContext.androidContext,
|
||||
logger = logger
|
||||
) {
|
||||
// we assume that the bridge is reloaded the next time we connect to it
|
||||
// Indicates if the bridge has been reloaded at least once in this session
|
||||
private var isBridgeReloaded = false
|
||||
|
||||
/**
|
||||
* Bridge connection status. Use [isBridgeConnected] to guard JNI-dependent operations.
|
||||
*/
|
||||
@Volatile
|
||||
private var isBridgeConnected = false
|
||||
|
||||
/**
|
||||
* Initializes the scripting environment and establishes bridge-aware lifecycle observers.
|
||||
*/
|
||||
fun init() {
|
||||
buildModuleObject = { module ->
|
||||
putConst("currentSide", this, BindingSide.CORE.key)
|
||||
@@ -32,11 +47,14 @@ class CoreScriptRuntime(
|
||||
|
||||
modContext.bridgeClient.addOnConnectedCallback(initNow = true) {
|
||||
modContext.bridgeClient.getScriptingInterface()?.let { scriptingInterface ->
|
||||
logger.info("JNI Bridge established. Initializing scripts...")
|
||||
scripting = scriptingInterface
|
||||
isBridgeConnected = true
|
||||
|
||||
if (!isBridgeReloaded) {
|
||||
scriptingInterface.enabledScripts.forEach { path ->
|
||||
runCatching {
|
||||
logger.verbose("Loading script: $path")
|
||||
load(path, scriptingInterface.getScriptContent(path))
|
||||
}.onFailure {
|
||||
logger.error("Failed to load script $path", it)
|
||||
@@ -46,6 +64,7 @@ class CoreScriptRuntime(
|
||||
|
||||
scriptingInterface.registerAutoReloadListener(object : AutoReloadListener.Stub() {
|
||||
override fun restartApp() {
|
||||
logger.info("Script change detected. Soft-restarting app...")
|
||||
modContext.softRestartApp()
|
||||
}
|
||||
})
|
||||
@@ -57,7 +76,18 @@ class CoreScriptRuntime(
|
||||
if (!isBridgeReloaded) {
|
||||
isBridgeReloaded = true
|
||||
}
|
||||
} ?: run {
|
||||
isBridgeConnected = false
|
||||
logger.error("JNI Bridge callback triggered but interface is null.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely iterates over loaded modules only when the JNI bridge is confirmed connected.
|
||||
*/
|
||||
override fun eachModule(f: JSModule.() -> Unit) {
|
||||
if (!isBridgeConnected) return
|
||||
super.eachModule(f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.data.FriendLinkType
|
||||
import me.eternal.purrfectsnap.common.database.impl.ConversationMessage
|
||||
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
|
||||
import me.eternal.purrfectsnap.common.scripting.JSModule
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager
|
||||
import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
36
native/rust/Cargo.lock
generated
@@ -107,9 +107,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.9.0"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
@@ -563,9 +563,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
@@ -821,18 +821,28 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.217"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.217"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -948,22 +958,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.44"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
|
||||
@@ -8,12 +8,17 @@ pub fn native_config() -> NativeConfig {
|
||||
NATIVE_CONFIG.lock().unwrap().as_ref().expect("NativeConfig not loaded").clone()
|
||||
}
|
||||
|
||||
/// Native configuration structure mirrored from 'NativeConfig.kt'.
|
||||
///
|
||||
/// CRITICAL: Fields must maintain 1:1 parity with the Kotlin implementation.
|
||||
/// Mismatches in field names, types, or order will result in a JNI SIGABRT.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct NativeConfig {
|
||||
pub disable_bitmoji: bool,
|
||||
pub disable_metrics: bool,
|
||||
pub valdi_hooks: bool,
|
||||
pub custom_emoji_font_path: Option<String>,
|
||||
pub debug_font_redirect: bool,
|
||||
}
|
||||
|
||||
impl NativeConfig {
|
||||
@@ -41,6 +46,7 @@ impl NativeConfig {
|
||||
disable_metrics: get_boolean!("disableMetrics"),
|
||||
valdi_hooks: get_boolean!("valdiHooks"),
|
||||
custom_emoji_font_path: get_string!("customEmojiFontPath"),
|
||||
debug_font_redirect: get_boolean!("debugFontRedirect"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use std::{cell::Cell, ffi::{CStr, CString}};
|
||||
|
||||
use nix::libc::{self, c_uint};
|
||||
|
||||
use crate::{config, def_hook, dobby_hook_sym};
|
||||
|
||||
thread_local! {
|
||||
@@ -23,6 +21,8 @@ fn should_redirect_font(pathname: &str) -> bool {
|
||||
file_name.contains("emoji")
|
||||
|| file_name == "noto_color_emoji.ttf"
|
||||
|| file_name == "samsungcoloremoji.ttf"
|
||||
|| file_name == "coloremojifont.ttf"
|
||||
|| file_name == "coloros_color_emoji.ttf"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option<i32> {
|
||||
Ok(c_font_path) => {
|
||||
let fd = FONT_REDIRECT_IN_PROGRESS.with(|guard| {
|
||||
let was_active = guard.replace(true);
|
||||
let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode) };
|
||||
let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const libc::c_char, flags, mode) };
|
||||
guard.set(was_active);
|
||||
fd
|
||||
});
|
||||
@@ -41,6 +41,9 @@ fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option<i32> {
|
||||
debug!("redirected emoji font open to {}", font_path);
|
||||
Some(fd)
|
||||
} else {
|
||||
if config::native_config().debug_font_redirect {
|
||||
panic!("Failed to open custom emoji font: {}", font_path);
|
||||
}
|
||||
debug!("failed to open custom emoji font path (fd={}): {}", fd, font_path);
|
||||
None
|
||||
}
|
||||
@@ -61,7 +64,7 @@ def_hook!(
|
||||
}
|
||||
|
||||
if !path.is_null() {
|
||||
if let Ok(pathname) = CStr::from_ptr(path).to_str() {
|
||||
if let Ok(pathname) = unsafe { CStr::from_ptr(path as *const libc::c_char) }.to_str() {
|
||||
if should_redirect_font(pathname) {
|
||||
if let Some(fd) = open_custom_font_fd(flags, mode) {
|
||||
return fd;
|
||||
@@ -74,10 +77,33 @@ def_hook!(
|
||||
}
|
||||
);
|
||||
|
||||
def_hook!(
|
||||
openat_hook,
|
||||
i32,
|
||||
|dirfd: i32, path: *const u8, flags: i32, mode: c_uint| {
|
||||
if FONT_REDIRECT_IN_PROGRESS.with(|guard| guard.get()) {
|
||||
return openat_hook_original.unwrap()(dirfd, path, flags, mode);
|
||||
}
|
||||
|
||||
if !path.is_null() {
|
||||
if let Ok(pathname) = unsafe { CStr::from_ptr(path as *const libc::c_char) }.to_str() {
|
||||
if should_redirect_font(pathname) {
|
||||
if let Some(fd) = open_custom_font_fd(flags, mode) {
|
||||
return fd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
openat_hook_original.unwrap()(dirfd, path, flags, mode)
|
||||
}
|
||||
);
|
||||
|
||||
pub fn init() {
|
||||
if config::native_config().custom_emoji_font_path.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
dobby_hook_sym!("libc.so", "open", open_hook);
|
||||
dobby_hook_sym!("libc.so", "openat", openat_hook);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ pub struct ValdiModule {
|
||||
|
||||
impl ValdiModule {
|
||||
pub fn parse(buffer: Vec<u8>) -> Result<ValdiModule, Error> {
|
||||
if buffer.len() < 8 {
|
||||
return Err(Error::new(std::io::ErrorKind::InvalidData, "Buffer too small"));
|
||||
}
|
||||
let mut offset = 0;
|
||||
let magic = u32::from_be_bytes([buffer[offset], buffer[offset + 1], buffer[offset + 2], buffer[offset + 3]]);
|
||||
|
||||
@@ -62,14 +65,13 @@ impl ValdiModule {
|
||||
}
|
||||
|
||||
fn read_u32(buffer: &Vec<u8>, offset: &mut usize) -> Result<(u32, bool), Error> {
|
||||
let b1 = buffer[*offset] as u32;
|
||||
let b2 = buffer[*offset + 1] as u32;
|
||||
let b3 = buffer[*offset + 2] as u32;
|
||||
let b4 = (buffer[*offset + 3] & 0x7f) as u32;
|
||||
let has_padding = (buffer[*offset + 3] & 0x80) != 0;
|
||||
let bytes = [buffer[*offset], buffer[*offset + 1], buffer[*offset + 2], buffer[*offset + 3]];
|
||||
let value = u32::from_be_bytes(bytes);
|
||||
let has_padding = (value & 0x80000000) != 0;
|
||||
let tag_size = value & 0x7FFFFFFF;
|
||||
|
||||
*offset += 4;
|
||||
Ok((b1 | (b2 << 8) | (b3 << 16) | (b4 << 24), has_padding))
|
||||
Ok((tag_size, has_padding))
|
||||
}
|
||||
|
||||
let (tag_size, has_padding) = read_u32(&buffer, &mut offset)?;
|
||||
@@ -98,10 +100,8 @@ impl ValdiModule {
|
||||
let mut tag_buffer = Vec::new();
|
||||
|
||||
fn write_u32(buffer: &mut Vec<u8>, value: u32, has_padding: bool) {
|
||||
buffer.push(value as u8);
|
||||
buffer.push(((value >> 8) & 0xff) as u8);
|
||||
buffer.push(((value >> 16) & 0xff) as u8);
|
||||
buffer.push(((value >> 24) & 0x7f) as u8 | if has_padding { 0x80 } else { 0x00 });
|
||||
let encoded_value = (value & 0x7FFFFFFF) | if has_padding { 0x80000000 } else { 0 };
|
||||
buffer.extend_from_slice(&encoded_value.to_be_bytes());
|
||||
}
|
||||
|
||||
fn write_tag(buffer: &mut Vec<u8>, tag: ModuleTag) {
|
||||
@@ -125,7 +125,7 @@ impl ValdiModule {
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
buffer.extend_from_slice(&[0x33, 0xc6, 0, 1]);
|
||||
buffer.extend_from_slice(&(tag_buffer.len() as u32).to_le_bytes());
|
||||
buffer.extend_from_slice(&(tag_buffer.len() as u32).to_be_bytes());
|
||||
buffer.extend(tag_buffer);
|
||||
|
||||
buffer
|
||||
|
||||
@@ -16,7 +16,10 @@ def_hook!(
|
||||
if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) {
|
||||
return buffer.len() as i32;
|
||||
}
|
||||
aasset_get_length_original.unwrap()(arg0)
|
||||
if let Some(original) = aasset_get_length_original {
|
||||
return original(arg0);
|
||||
}
|
||||
0
|
||||
}
|
||||
);
|
||||
|
||||
@@ -27,7 +30,10 @@ def_hook!(
|
||||
if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) {
|
||||
return buffer.as_ptr() as *const c_void;
|
||||
}
|
||||
aasset_get_buffer_original.unwrap()(arg0)
|
||||
if let Some(original) = aasset_get_buffer_original {
|
||||
return original(arg0);
|
||||
}
|
||||
std::ptr::null()
|
||||
}
|
||||
);
|
||||
|
||||
@@ -35,53 +41,89 @@ def_hook!(
|
||||
aasset_manager_open,
|
||||
*mut c_void,
|
||||
|arg0: *mut c_void, arg1: *const u8, arg2: i32| {
|
||||
let handle = aasset_manager_open_original.unwrap()(arg0, arg1, arg2);
|
||||
let original_fn = match aasset_manager_open_original {
|
||||
Some(f) => f,
|
||||
None => return std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let path = std::ffi::CStr::from_ptr(arg1).to_str().unwrap_or_default();
|
||||
if !handle.is_null() && path.starts_with("bridge_observables") {
|
||||
let asset_buffer = aasset_get_buffer_original.unwrap()(handle);
|
||||
let asset_length = aasset_get_length_original.unwrap()(handle);
|
||||
debug!("asset buffer: {:p}, length: {}", asset_buffer, asset_length);
|
||||
let handle = original_fn(arg0, arg1, arg2);
|
||||
if handle.is_null() {
|
||||
return handle;
|
||||
}
|
||||
|
||||
let loader_data = LOADER_DATA.lock().unwrap().clone().expect("No loader data");
|
||||
let path_cstr = unsafe { std::ffi::CStr::from_ptr(arg1 as *const std::os::raw::c_char) };
|
||||
let path = path_cstr.to_str().unwrap_or_default();
|
||||
|
||||
// Only target compressed Valdi bridge observables
|
||||
if path.ends_with(".zst") && path.contains("bridge_observables") {
|
||||
let get_buffer_fn = match aasset_get_buffer_original {
|
||||
Some(f) => f,
|
||||
None => return handle,
|
||||
};
|
||||
let get_length_fn = match aasset_get_length_original {
|
||||
Some(f) => f,
|
||||
None => return handle,
|
||||
};
|
||||
|
||||
let archive_buffer: Vec<u8> = std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec();
|
||||
let decompressed = zstd::stream::decode_all(&archive_buffer[..]).expect("Failed to decompress valdi archive");
|
||||
let mut valdi_module = ValdiModule::parse(decompressed).expect("Failed to parse valdi module");
|
||||
|
||||
let mut tags = valdi_module.get_tags();
|
||||
let mut new_tags = Vec::new();
|
||||
|
||||
for (tag1, _) in tags.iter_mut() {
|
||||
let name = tag1.to_string().unwrap_or_default();
|
||||
if !name.ends_with("src/utils/converter.js") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let old_file_name = name.split_once(".").unwrap().0.to_owned() + rand::random::<u32>().to_string().as_str();
|
||||
tag1.set_buffer((old_file_name.to_owned() + ".js").as_bytes().to_vec());
|
||||
let original_module_path = path.split_once(".").unwrap().0.to_owned() + "/" + &old_file_name;
|
||||
|
||||
let hooked_module = format!("{};module.exports = require(\"{}\");", loader_data, original_module_path);
|
||||
|
||||
new_tags.push(
|
||||
(
|
||||
ModuleTag::new(true, name.as_bytes().to_vec()),
|
||||
ModuleTag::new(true, hooked_module.as_bytes().to_vec())
|
||||
)
|
||||
);
|
||||
|
||||
debug!("Valdi loader injected in {}", name);
|
||||
break;
|
||||
let asset_buffer = get_buffer_fn(handle);
|
||||
let asset_length = get_length_fn(handle);
|
||||
|
||||
if asset_buffer.is_null() || asset_length <= 0 {
|
||||
return handle;
|
||||
}
|
||||
|
||||
tags.extend(new_tags);
|
||||
valdi_module.set_tags(tags);
|
||||
let loader_data = match LOADER_DATA.lock().unwrap().clone() {
|
||||
Some(data) => data,
|
||||
None => {
|
||||
warn!("Valdi loader data not yet initialized for {}", path);
|
||||
return handle;
|
||||
}
|
||||
};
|
||||
|
||||
let compressed = valdi_module.to_bytes();
|
||||
let compressed = zstd::stream::encode_all(&compressed[..], 3).expect("Failed to compress");
|
||||
let archive_buffer: Vec<u8> = unsafe {
|
||||
std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec()
|
||||
};
|
||||
|
||||
let decompressed = match zstd::stream::decode_all(&archive_buffer[..]) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to decompress Valdi archive {}: {}", path, e);
|
||||
return handle;
|
||||
}
|
||||
};
|
||||
|
||||
AASSET_MAP.lock().unwrap().insert(handle as usize, compressed);
|
||||
let valdi_module = match ValdiModule::parse(decompressed) {
|
||||
Ok(module) => module,
|
||||
Err(e) => {
|
||||
error!("Failed to parse Valdi module {}: {}", path, e);
|
||||
return handle;
|
||||
}
|
||||
};
|
||||
|
||||
let mut tags = valdi_module.get_tags();
|
||||
let mut found = false;
|
||||
|
||||
for (tag1, tag2) in tags.iter_mut() {
|
||||
let name = tag1.to_string().unwrap_or_default();
|
||||
if name.ends_with("src/utils/converter.js") {
|
||||
let mut hooked_content = loader_data.as_bytes().to_vec();
|
||||
hooked_content.extend_from_slice(tag2.get_buffer());
|
||||
*tag2 = ModuleTag::new(true, hooked_content);
|
||||
found = true;
|
||||
debug!("Valdi loader prepended to {}", name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
let compressed = valdi_module.to_bytes();
|
||||
match zstd::stream::encode_all(&compressed[..], 3) {
|
||||
Ok(compressed_data) => {
|
||||
AASSET_MAP.lock().unwrap().insert(handle as usize, compressed_data);
|
||||
},
|
||||
Err(e) => error!("Failed to re-compress Valdi module: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
handle
|
||||
}
|
||||
@@ -89,16 +131,19 @@ def_hook!(
|
||||
|
||||
def_hook!(
|
||||
aasset_close,
|
||||
c_void,
|
||||
(),
|
||||
|handle: *mut c_void| {
|
||||
AASSET_MAP.lock().unwrap().remove(&(handle as usize));
|
||||
aasset_close_original.unwrap()(handle)
|
||||
if let Some(original) = aasset_close_original {
|
||||
original(handle);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
pub fn set_valdi_loader(mut env: JNIEnv, _: *mut c_void, code: JString) {
|
||||
let new_code = get_jni_string(&mut env, code).expect("Failed to get loader code");
|
||||
LOADER_DATA.lock().unwrap().replace(new_code);
|
||||
if let Ok(new_code) = get_jni_string(&mut env, code) {
|
||||
LOADER_DATA.lock().unwrap().replace(new_code);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init() {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package me.eternal.purrfectsnap.nativelib
|
||||
|
||||
/**
|
||||
* Configuration schema for the native layer.
|
||||
*
|
||||
* CRITICAL: This class MUST maintain 1:1 field parity with 'native/rust/src/config.rs'.
|
||||
* Any modification to field names, types, or order without a corresponding change
|
||||
* in the Rust implementation will cause a JNI SIGABRT (crash on launch).
|
||||
*/
|
||||
data class NativeConfig(
|
||||
@JvmField
|
||||
val disableBitmoji: Boolean = false,
|
||||
@@ -9,4 +16,6 @@ data class NativeConfig(
|
||||
val valdiHooks: Boolean = false,
|
||||
@JvmField
|
||||
val customEmojiFontPath: String? = null,
|
||||
@JvmField
|
||||
val debugFontRedirect: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -128,4 +128,7 @@ include(":core")
|
||||
include(":valdi")
|
||||
include(":app")
|
||||
include(":mapper")
|
||||
include(":manager")
|
||||
include(":native")
|
||||
|
||||
project(":manager").projectDir = file("mapper")
|
||||
|
||||
Reference in New Issue
Block a user