Merge gitea/dev into dev (resolve conflict: keep GitHub debug/story features)

This commit is contained in:
root
2026-05-30 13:37:30 +02:00
73 changed files with 9321 additions and 4119 deletions

View File

@@ -113,7 +113,6 @@ android {
compose = true
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
@@ -166,6 +165,9 @@ android {
val releaseKeyAlias = gradleOrEnv("PS_RELEASE_KEY_ALIAS", providers)
if (releaseStore.exists() && !releaseStorePass.isNullOrBlank() && !releaseKeyAlias.isNullOrBlank()) {
signingConfig = signingConfigs.getByName("release")
} else {
// Keep local release builds installable when private release credentials are unavailable.
signingConfig = signingConfigs.getByName("debug")
}
}
debug {
@@ -356,6 +358,7 @@ afterEvaluate {
}
}
}
}
properties["debug_flavor"]?.let {

View File

@@ -5,6 +5,9 @@ import android.content.Intent
import android.os.IBinder
import android.os.ParcelFileDescriptor
import android.os.RemoteException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
@@ -219,19 +222,42 @@ class BridgeService : Service() {
triggerScopeSync(SocialScope.getByName(scope), id, true)
}
private val friendAccumulator = mutableListOf<MessagingFriendInfo>()
private val groupAccumulator = mutableListOf<MessagingGroupInfo>()
override fun passGroupsAndFriends(
groups: List<String>,
friends: List<String>
friends: List<String>,
chunkIndex: Int,
totalChunks: Int
) {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(parsedFriends, parsedGroups)
synchronized(friendAccumulator) {
if (chunkIndex == 0) {
friendAccumulator.clear()
groupAccumulator.clear()
}
remoteSideContext.log.verbose("Received chunk $chunkIndex/$totalChunks: ${groups.size} groups, ${friends.size} friends")
friendAccumulator.addAll(friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) })
groupAccumulator.addAll(groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) })
if (chunkIndex == totalChunks - 1) {
val finalFriends = friendAccumulator.toList()
val finalGroups = groupAccumulator.toList()
friendAccumulator.clear()
groupAccumulator.clear()
remoteSideContext.coroutineScope.launch(Dispatchers.IO) {
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(finalFriends, finalGroups)
}
remoteSideContext.database.replaceMessagingData(finalFriends, finalGroups)
remoteSideContext.database.messagingDataFlow.tryEmit(finalFriends to finalGroups)
}
}
}
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
}
override fun getScopeNotes(id: String): String? {

View File

@@ -283,7 +283,7 @@ class DownloadProcessor (
while (true) {
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
if (existingFile.length() == inputFile.length()) {
if (existingFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get()) {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
@@ -376,7 +376,7 @@ class DownloadProcessor (
var destFile = File(destDir, fileName)
var suffix = 1
while (destFile.exists()) {
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
if (destFile.length() == inputFile.length() && !remoteSideContext.config.root.downloader.allowDuplicate.get() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
}
destFile = File(destDir, appendNameSuffix(fileName, suffix++))

View File

@@ -151,31 +151,37 @@ class FFMpegProcessor(
}
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"
}
fun applyVideoArguments() {
outputArguments += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
outputArguments += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
outputArguments += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
outputArguments += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
}
when (args.action) {
Action.DOWNLOAD_DASH -> {
applyVideoArguments()
outputArguments += "-ss" to "'${args.startTime}ms'"
if (args.duration != null) {
outputArguments += "-t" to "'${args.duration}ms'"
}
}
Action.MERGE_OVERLAY -> {
applyVideoArguments()
inputArguments += "-i" to args.overlay!!.absolutePath
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)\""
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)\""
}
Action.CONVERSION -> {
if (ffmpegOptions.customAudioCodec.isEmpty()) {
outputArguments -= "-c:a"
}
outputArguments -= "-c:v"
args.videoCodec?.let {
applyVideoArguments()
outputArguments -= "-c:v"
outputArguments += "-c:v" to it
} ?: run {
outputArguments += "-vn"
@@ -186,6 +192,7 @@ class FFMpegProcessor(
}
}
Action.MERGE_MEDIA -> {
applyVideoArguments()
inputArguments.clear()
val filesInfo = args.inputs.mapNotNull { file ->
runCatching {
@@ -211,7 +218,7 @@ class FFMpegProcessor(
filterSecondPart.append("[v$index][$index:a]")
} else {
containsNoSound = true
filterSecondPart.append("[v$index][${filesInfo.size}]")
filterSecondPart.append("[v$index][${filesInfo.size}:a]")
}
inputArguments += "-i" to file
}
@@ -228,9 +235,9 @@ class FFMpegProcessor(
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]\""
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() }
}
@@ -264,8 +271,8 @@ class FFMpegProcessor(
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-filter_complex" to filterParts.toString()
outputArguments += "-map" to "[aout]"
}
}
outputArguments += args.output.absolutePath

View File

@@ -5,6 +5,8 @@ import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.util.SQLiteDatabaseHelper
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -15,7 +17,11 @@ class AppDatabase(
val executor: ExecutorService = Executors.newSingleThreadExecutor()
lateinit var database: SQLiteDatabase
var receiveMessagingDataCallback: (friends: List<MessagingFriendInfo>, groups: List<MessagingGroupInfo>) -> Unit = { _, _ -> }
// Multi-subscriber event stream for messaging data updates
val messagingDataFlow = MutableSharedFlow<Pair<List<MessagingFriendInfo>, List<MessagingGroupInfo>>>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
fun executeAsync(block: () -> Unit) {
executor.execute {
@@ -113,6 +119,22 @@ class AppDatabase(
"id CHAR(36) PRIMARY KEY",
"content TEXT",
),
"assistant_registry" to listOf(
"id VARCHAR PRIMARY KEY",
"kind VARCHAR",
"title VARCHAR",
"category VARCHAR",
"path TEXT",
"description TEXT",
"settingKey VARCHAR",
"screenRoute VARCHAR",
"allowedActions TEXT",
"allowedValues TEXT",
"aliases TEXT",
"commonTypos TEXT",
"examples TEXT",
"searchTokens TEXT",
),
))
}
}

View File

@@ -0,0 +1,93 @@
package me.eternal.purrfectsnap.storage
import android.content.ContentValues
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
import org.json.JSONArray
data class AssistantRegistryEntry(
val id: String,
val kind: String,
val title: String,
val category: String,
val path: String,
val description: String,
val settingKey: String? = null,
val screenRoute: String? = null,
val allowedActions: List<String> = emptyList(),
val allowedValues: List<String> = emptyList(),
val aliases: List<String> = emptyList(),
val commonTypos: List<String> = emptyList(),
val examples: List<String> = emptyList(),
val searchTokens: List<String> = emptyList()
)
private fun List<String>.toJsonArrayString(): String = JSONArray(this).toString()
private fun parseStringList(raw: String?): List<String> {
if (raw.isNullOrBlank()) return emptyList()
return runCatching {
val array = JSONArray(raw)
buildList {
for (index in 0 until array.length()) {
array.optString(index).takeIf { it.isNotBlank() }?.let(::add)
}
}
}.getOrDefault(emptyList())
}
fun AppDatabase.replaceAssistantRegistry(entries: List<AssistantRegistryEntry>) {
database.beginTransaction()
try {
database.execSQL("DELETE FROM assistant_registry")
entries.forEach { entry ->
database.insert(
"assistant_registry",
null,
ContentValues().apply {
put("id", entry.id)
put("kind", entry.kind)
put("title", entry.title)
put("category", entry.category)
put("path", entry.path)
put("description", entry.description)
put("settingKey", entry.settingKey)
put("screenRoute", entry.screenRoute)
put("allowedActions", entry.allowedActions.toJsonArrayString())
put("allowedValues", entry.allowedValues.toJsonArrayString())
put("aliases", entry.aliases.toJsonArrayString())
put("commonTypos", entry.commonTypos.toJsonArrayString())
put("examples", entry.examples.toJsonArrayString())
put("searchTokens", entry.searchTokens.toJsonArrayString())
}
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
}
fun AppDatabase.getAssistantRegistryEntries(): List<AssistantRegistryEntry> {
return database.rawQuery("SELECT * FROM assistant_registry", null).use { cursor ->
val entries = mutableListOf<AssistantRegistryEntry>()
while (cursor.moveToNext()) {
entries += AssistantRegistryEntry(
id = cursor.getStringOrNull("id") ?: continue,
kind = cursor.getStringOrNull("kind") ?: "feature",
title = cursor.getStringOrNull("title") ?: "",
category = cursor.getStringOrNull("category") ?: "",
path = cursor.getStringOrNull("path") ?: "",
description = cursor.getStringOrNull("description") ?: "",
settingKey = cursor.getStringOrNull("settingKey"),
screenRoute = cursor.getStringOrNull("screenRoute"),
allowedActions = parseStringList(cursor.getStringOrNull("allowedActions")),
allowedValues = parseStringList(cursor.getStringOrNull("allowedValues")),
aliases = parseStringList(cursor.getStringOrNull("aliases")),
commonTypos = parseStringList(cursor.getStringOrNull("commonTypos")),
examples = parseStringList(cursor.getStringOrNull("examples")),
searchTokens = parseStringList(cursor.getStringOrNull("searchTokens"))
)
}
entries
}
}

View File

@@ -97,15 +97,16 @@ fun AppDatabase.replaceMessagingData(
database.beginTransaction()
try {
friends.forEach { friend ->
// Industrial Filter: Only update existing friends, never auto-insert new ones.
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",
"UPDATE friends SET dmConversationId = ?, displayName = ?, mutableUsername = ?, bitmojiId = ?, selfieId = ? WHERE userId = ?",
arrayOf<Any?>(
friend.userId,
friend.dmConversationId,
friend.displayName,
friend.mutableUsername,
friend.bitmojiId,
friend.selfieId
friend.selfieId,
friend.userId
)
)
@@ -124,12 +125,13 @@ fun AppDatabase.replaceMessagingData(
}
groups.forEach { group ->
// Industrial Filter: Only update existing groups.
database.execSQL(
"INSERT OR REPLACE INTO groups (conversationId, name, participantsCount) VALUES (?, ?, ?)",
"UPDATE groups SET name = ?, participantsCount = ? WHERE conversationId = ?",
arrayOf<Any?>(
group.conversationId,
group.name,
group.participantsCount
group.participantsCount,
group.conversationId
)
)
}
@@ -139,10 +141,8 @@ fun AppDatabase.replaceMessagingData(
database.endTransaction()
}
// Notify with the full updated list from the DB
val allFriends = getFriends(descOrder = true)
val allGroups = getGroups()
receiveMessagingDataCallback(allFriends, allGroups)
// Notify all observers with the raw sync data (AddFriendDialog needs this)
messagingDataFlow.tryEmit(friends to groups)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -133,16 +133,16 @@ class MainActivity : ComponentActivity() {
if (shouldShowAbiWarning) {
AestheticDialog(
onDismissRequest = {},
title = managerContext.translation["wrong_apk_title"],
title = managerContext.translation["setup.activity.wrong_apk_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = managerContext.translation["common.close"],
confirmButtonText = managerContext.translation["setup.activity.close_button"],
onConfirm = { (context as? Activity)?.finishAffinity() },
showCloseButton = false,
opaque = true,
customContent = {
Text(
text = managerContext.translation["wrong_apk_message"],
text = managerContext.translation["setup.activity.wrong_apk_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)

View File

@@ -269,7 +269,7 @@ fun FloatingTopBar(
if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx()
}
},
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {

View File

@@ -113,8 +113,8 @@ object Updater {
fun getLatestRelease(channel: Channel): LatestRelease? {
return cache.getOrPut(channel) {
if (BuildConfig.DEBUG) {
fetchLatestDebugCI() ?: fetchLatestRelease(channel)
if (BuildConfig.DEBUG && channel == Channel.STABLE) {
fetchLatestDebugCI() ?: fetchLatestRelease(Channel.STABLE)
} else {
fetchLatestRelease(channel)
}

View File

@@ -181,7 +181,6 @@ class FeaturesRootSection : Routes.Route() {
}
internal fun getRandomizedProfileSnapshot(): String {
context.config.load()
return context.config.root.experimental.spoof.randomizeDeviceProfile.currentProfileSnapshot.getNullable()
?.takeIf { it.isNotBlank() }
?: (context.translation["manager.dialogs.randomize_device_profile.empty"]
@@ -231,9 +230,12 @@ class FeaturesRootSection : Routes.Route() {
?: error("Failed to read randomized profile backup")
val profile = RandomizedDeviceProfile.fromJson(importedJson)
val generationToken = UUID.randomUUID().toString()
val profileJson = profile.toJson().toString()
// Save to local prefs for legacy compatibility
context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
.edit()
.putString("randomized_device_profile", profile.toJson().toString())
.putString("randomized_device_profile", profileJson)
.putString("randomized_device_profile_token", generationToken)
.putString("android_id", profile.androidId)
.putString("advertising_id", profile.advertisingId)
@@ -246,6 +248,8 @@ class FeaturesRootSection : Routes.Route() {
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
randomizeConfig.profileGenerationToken.set(generationToken)
randomizeConfig.currentProfileSnapshot.set(profile.toJson().toString(2))
randomizeConfig.profileData.set(profileJson) // Shared storage fix
context.config.writeConfig()
onConfigChanged()
context.shortToast("Randomized profile restored. Restart Snapchat to apply it.")

View File

@@ -4,18 +4,14 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Surface
@@ -29,6 +25,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -38,10 +35,10 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.storage.clearRuleIds
import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.storage.setRule
@@ -140,9 +137,12 @@ class ManageRuleFeature : Routes.Route() {
}
val updateDispatcher = rememberAsyncUpdateDispatcher()
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) {
val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleIds(currentRuleType.key)
}
val currentRuleIdSet = remember(currentRuleIds.size) {
currentRuleIds.toSet()
}
fun setRuleState(newState: RuleState?) {
ruleState = newState
@@ -163,12 +163,12 @@ class ManageRuleFeature : Routes.Route() {
fun showAddFriendDialog() {
addFriendDialog = AddFriendDialog(
context = context,
pinnedIds = currentRuleIds,
pinnedIds = currentRuleIds.toList(),
actionHandler = Actions(
onFriendState = { friend, state ->
context.database.setRule(friend.userId, currentRuleType.key, state)
if (state) {
currentRuleIds.add(friend.userId)
if (!currentRuleIdSet.contains(friend.userId)) currentRuleIds.add(friend.userId)
} else {
currentRuleIds.remove(friend.userId)
}
@@ -176,16 +176,16 @@ class ManageRuleFeature : Routes.Route() {
onGroupState = { group, state ->
context.database.setRule(group.conversationId, currentRuleType.key, state)
if (state) {
currentRuleIds.add(group.conversationId)
if (!currentRuleIdSet.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
} else {
currentRuleIds.remove(group.conversationId)
}
},
getFriendState = { friend ->
currentRuleIds.contains(friend.userId)
currentRuleIdSet.contains(friend.userId)
},
getGroupState = { group ->
currentRuleIds.contains(group.conversationId)
currentRuleIdSet.contains(group.conversationId)
}
)
)
@@ -230,59 +230,62 @@ class ManageRuleFeature : Routes.Route() {
title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() },
modifier = Modifier
.zIndex(2f)
.zIndex(10f)
.onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight
}
)
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = topBarHeight + 10.dp)
.padding(horizontal = 12.dp, vertical = 10.dp)
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
.padding(top = topBarHeight + 10.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
val headerShape = RoundedCornerShape(22.dp)
Surface(
shape = headerShape,
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
item {
val headerShape = RoundedCornerShape(22.dp)
Surface(
shape = headerShape,
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = context.translation[propertyKeyPair.key.propertyDescription()],
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = context.translation[propertyKeyPair.key.propertyDescription()] ?: "",
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
}
}
}
SelectRuleTypeRadio(
checked = ruleState == null,
text = translation["disable_state_option"],
onStateChanged = { setRuleState(null) }
) {
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
item {
SelectRuleTypeRadio(
checked = ruleState == null,
text = translation["disable_state_option"] ?: "Disabled",
onStateChanged = { setRuleState(null) }
) {
Text(text = translation["disable_state_subtext"] ?: "", fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
}
}
val manageLabel = when (ruleState) {
@@ -291,112 +294,120 @@ class ManageRuleFeature : Routes.Route() {
else -> null
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.WHITELIST,
text = translation["whitelist_state_option"],
onStateChanged = { setRuleState(RuleState.WHITELIST) }
) {
Text(
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.WHITELIST,
text = translation["whitelist_state_option"] ?: "Whitelist",
onStateChanged = { setRuleState(RuleState.WHITELIST) }
) {
Text(
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
) {
Text(text = translation["whitelist_state_button"])
}
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"],
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"])
}
}
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["dialog_clear_confirm_button"])
Text(text = translation["whitelist_state_button"] ?: "Manage")
}
}
}
Spacer(modifier = Modifier.height(routes.bottomPadding))
item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"] ?: "Blacklist",
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"] ?: "Manage")
}
}
}
item {
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"] ?: "Clear List",
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["dialog_clear_confirm_button"] ?: "Clear")
}
}
}
}
item {
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
}
}
}

View File

@@ -32,7 +32,6 @@ import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.scaleOnPress
class HomeAbout : Routes.Route() {
@@ -65,6 +64,7 @@ class HomeAbout : Routes.Route() {
name: String,
imageRes: Int,
avenirNext: FontFamily,
subtitle: String? = null,
modifier: Modifier = Modifier
) {
val tapSource = remember { MutableInteractionSource() }
@@ -85,7 +85,9 @@ class HomeAbout : Routes.Route() {
routes.retroGame.navigate()
}
},
modifier = modifier.scaleOnPress(tapSource),
modifier = modifier
.height(150.dp)
.scaleOnPress(tapSource),
interactionSource = tapSource,
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f),
@@ -94,9 +96,11 @@ class HomeAbout : Routes.Route() {
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(14.dp),
modifier = Modifier
.fillMaxSize()
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
verticalArrangement = Arrangement.Center
) {
Surface(
modifier = Modifier.size(64.dp),
@@ -111,15 +115,28 @@ class HomeAbout : Routes.Route() {
modifier = Modifier.fillMaxSize().clip(CircleShape)
)
}
PurrfectMarqueeText(
Text(
text = name,
color = Color.White,
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext
)
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
fontFamily = avenirNext,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
subtitle?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = it,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -127,6 +127,8 @@ import me.eternal.purrfectsnap.storage.getQuickTiles
import me.eternal.purrfectsnap.storage.setQuickTiles
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
@@ -271,27 +273,31 @@ class HomeRootSection : Routes.Route() {
icon: ImageVector,
label: String? = null,
contentDescription: String? = label,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Surface(
modifier = modifier.height(36.dp),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp),
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = contentDescription, tint = Color.White)
Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
label?.let {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = it,
color = Color.White,
fontSize = 13.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -323,13 +329,20 @@ class HomeRootSection : Routes.Route() {
@Composable
private fun RowScope.HomeActionChips() {
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.DEFAULT
)
TopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"]
label = context.translation["manager.routes.home_logs"],
modifier = Modifier
) { routes.homeLogs.navigate() }
TopBarActionChip(
icon = Icons.Filled.Info,
label = translation["manager.routes.home_about"]
label = translation["manager.routes.home_about"],
modifier = Modifier
) { routes.about.navigate() }
}
@@ -733,9 +746,8 @@ class HomeRootSection : Routes.Route() {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.wrapContentWidth(),
) {
Spacer(modifier = Modifier.weight(1f))
HomeActionChips()
}
}

View File

@@ -75,34 +75,9 @@ class HomeSettings : Routes.Route() {
internal fun scheduleUpdateCheck() {
val workManager = WorkManager.getInstance(context.androidContext)
val updateSettings = context.config.root.global.updateSettings
var configDirty = false
val autoUpdateCheck = updateSettings.autoUpdateCheck.getNullable() ?: run {
configDirty = true
updateSettings.autoUpdateCheck.set(true)
true
}
val frequency = updateSettings.updateCheckFrequency.getNullable() ?: run {
configDirty = true
updateSettings.updateCheckFrequency.set("daily")
"daily"
}
val updateChannel = updateSettings.updateChannel.getNullable() ?: run {
configDirty = true
updateSettings.updateChannel.set("stable")
"stable"
}
if (configDirty) {
context.config.writeConfig()
}
val autoUpdateCheck = updateSettings.autoUpdateCheck.get()
if (autoUpdateCheck) {
val repeatInterval = when (frequency) {
"daily" -> 1L
"weekly" -> 7L
"monthly" -> 30L
else -> 1L
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
@@ -112,10 +87,10 @@ class HomeSettings : Routes.Route() {
.putString("channel_description", translation["update_notification_channel_description"])
.putString("notification_title", translation["update_notification_title"])
.putString("notification_text", translation["update_notification_text"])
.putString("update_channel", updateChannel)
.putString("update_channel", "stable")
.build()
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(repeatInterval, TimeUnit.DAYS)
val workRequest = PeriodicWorkRequestBuilder<UpdateCheckWorker>(1, TimeUnit.DAYS)
.setConstraints(constraints)
.setInputData(inputData)
.build()

View File

@@ -279,8 +279,9 @@ class ManageScriptReposSection : Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("script") })
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(refreshTrigger.value) {
repositories = context.database.getRepositories("script")
}
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()

View File

@@ -26,9 +26,11 @@ import kotlinx.coroutines.*
import me.eternal.purrfectsnap.RemoteSideContext
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.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.storage.getFriends
import me.eternal.purrfectsnap.storage.getGroups
import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
@@ -223,32 +225,34 @@ class AddFriendDialog(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else {
this
coroutineScope.launch(Dispatchers.IO) {
val sortedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
val sortedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else {
// Priority sort for whitelisted groups
val whitelistedIds = context.database.getRuleIds(MessagingRuleType.STEALTH.key).toSet()
sortedWith { a, b ->
val aSelected = whitelistedIds.contains(a.conversationId)
val bSelected = whitelistedIds.contains(b.conversationId)
if (aSelected != bSelected) if (aSelected) -1 else 1
else a.name.compareTo(b.name, ignoreCase = true)
}
}
}
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else {
this
withContext(Dispatchers.Main) {
cachedFriends = sortedFriends
cachedGroups = sortedGroups
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
}
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
coroutineScope.launch {
applySnapshot(friends, groups)
}
}
// Initial database load
withContext(Dispatchers.IO) {
applySnapshot(
context.database.getFriends(descOrder = true),
@@ -256,20 +260,11 @@ class AddFriendDialog(
)
}
context.database.receiveMessagingDataCallback = updateSnapshot
// Real-time synchronization flow
context.requestSocialSnapshotRefresh()
coroutineScope.launch(Dispatchers.IO) {
repeat(25) {
delay(1000)
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
if (dbFriends.isNotEmpty() || dbGroups.isNotEmpty()) {
withContext(Dispatchers.Main) {
applySnapshot(dbFriends, dbGroups)
}
return@launch
}
coroutineScope.launch {
context.database.messagingDataFlow.collect { (friends, groups) ->
applySnapshot(friends, groups)
}
}
@@ -286,7 +281,6 @@ class AddFriendDialog(
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
@@ -346,6 +340,7 @@ class AddFriendDialog(
it.mutableUsername.contains(searchKeyword.value, ignoreCase = true) ||
it.displayName?.contains(searchKeyword.value, ignoreCase = true) == true
} ?: cachedFriends!!
val selectedFriendCount by remember(filteredFriends) {
derivedStateOf {
filteredFriends.count { friend ->
@@ -356,6 +351,16 @@ class AddFriendDialog(
val hasFriendsSelected = selectedFriendCount > 0
val allFriendsSelected = filteredFriends.isNotEmpty() && selectedFriendCount == filteredFriends.size
val selectedGroupCount by remember(filteredGroups) {
derivedStateOf {
filteredGroups.count { group ->
stateCache[group.conversationId] ?: actionHandler.getGroupState(group)
}
}
}
val hasGroupsSelected = selectedGroupCount > 0
val allGroupsSelected = filteredGroups.isNotEmpty() && selectedGroupCount == filteredGroups.size
DialogHeader(searchKeyword)
LazyColumn(
@@ -365,14 +370,54 @@ class AddFriendDialog(
) {
item {
if (filteredGroups.isNotEmpty()) {
Text(
text = translation["category_groups"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp, top = 8.dp),
color = Color.White
)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = translation["category_groups"],
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredGroups.forEach { group ->
stateCache[group.conversationId] = true
actionHandler.onGroupState(group, true)
}
}
},
enabled = !allGroupsSelected
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allGroupsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
)
}
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
filteredGroups.forEach { group ->
stateCache[group.conversationId] = false
actionHandler.onGroupState(group, false)
}
}
},
enabled = hasGroupsSelected
) {
Text(
text = translation["unselect_all_button"],
color = if (hasGroupsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
)
}
}
}
}
}
@@ -417,11 +462,7 @@ class AddFriendDialog(
) {
Text(
text = context.translation["manager.dialogs.messaging_action.select_all_button"],
color = if (allFriendsSelected) {
Color.White.copy(alpha = 0.45f)
} else {
PurrfectPalette.glowSecondary
}
color = if (allFriendsSelected) Color.White.copy(alpha = 0.45f) else PurrfectPalette.glowSecondary
)
}
TextButton(
@@ -437,11 +478,7 @@ class AddFriendDialog(
) {
Text(
text = translation["unselect_all_button"],
color = if (hasFriendsSelected) {
PurrfectPalette.glowPrimary
} else {
Color.White.copy(alpha = 0.45f)
}
color = if (hasFriendsSelected) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.45f)
)
}
}

View File

@@ -35,6 +35,7 @@ import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.coil.ImageRequestHelper
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import java.io.File
import java.text.DateFormat
import java.util.Date
@@ -44,12 +45,11 @@ import kotlin.math.absoluteValue
class LoggedStories : Routes.Route() {
override val title: @Composable () -> Unit = {
val navBackStackEntry by routes.navController.currentBackStackEntryAsState()
val text = remember(navBackStackEntry) {
navBackStackEntry?.arguments?.getString("id")?.let {
context.database.getFriendInfo(it)?.displayName
}
val userId = navBackStackEntry?.arguments?.getString("id")
val displayName by rememberAsyncMutableState(defaultValue = null) {
userId?.let { context.database.getFriendInfo(it)?.displayName }
}
text?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
displayName?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
@OptIn(ExperimentalCoilApi::class, ExperimentalLayoutApi::class)
@@ -57,7 +57,9 @@ class LoggedStories : Routes.Route() {
val userId = navBackStackEntry.arguments?.getString("id") ?: return@content
val stories = remember { mutableStateListOf<StoryData>() }
val friendInfo = remember { context.database.getFriendInfo(userId) }
val friendInfo by rememberAsyncMutableState(defaultValue = null) {
context.database.getFriendInfo(userId)
}
var lastStoryTimestamp by remember { mutableLongStateOf(Long.MAX_VALUE) }
var selectedStory by remember { mutableStateOf<StoryData?>(null) }

View File

@@ -0,0 +1,30 @@
package me.eternal.purrfectsnap.ui.manager.pages.social
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.storage.getFriends
internal fun RemoteSideContext.sortSocialFriends(
friends: List<MessagingFriendInfo>,
pinnedIds: List<String>? = null
): List<MessagingFriendInfo> {
val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet()
val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get()
return friends.sortedWith { a, b ->
val aSelected = whitelistedIds.contains(a.userId)
val bSelected = whitelistedIds.contains(b.userId)
if (aSelected != bSelected) {
return@sortedWith if (aSelected) -1 else 1
}
if (sortByStreakLength) {
val aStreak = a.streaks?.length ?: 0
val bStreak = b.streaks?.length ?: 0
if (aStreak != bStreak) return@sortedWith bStreak.compareTo(aStreak)
}
(a.displayName ?: a.mutableUsername).compareTo(b.displayName ?: b.mutableUsername, ignoreCase = true)
}
}

View File

@@ -35,8 +35,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
@@ -53,10 +52,32 @@ class SocialRootSection : Routes.Route() {
internal var friendList: List<MessagingFriendInfo> by mutableStateOf(emptyList())
internal var groupList: List<MessagingGroupInfo> by mutableStateOf(emptyList())
internal fun updateScopeLists() {
context.coroutineScope.launch {
friendList = context.database.getFriends(descOrder = true)
groupList = context.database.getGroups()
@Composable
fun SocialDataController() {
LaunchedEffect(Unit) {
// Initial data fetch from the database
withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
}
// Real-time synchronization from the bridge
context.database.messagingDataFlow.collect {
withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups()
val sortedFriends = context.sortSocialFriends(dbFriends)
withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
}
}
}
}
@@ -124,11 +145,6 @@ class SocialRootSection : Routes.Route() {
addFriendDialog?.Content {
addFriendDialog = null
}
DisposableEffect(Unit) {
onDispose {
updateScopeLists()
}
}
}
FloatingActionButton(
@@ -158,8 +174,7 @@ class SocialRootSection : Routes.Route() {
},
getFriendState = { friend -> context.database.getFriendInfo(friend.userId) != null },
getGroupState = { group -> context.database.getGroupInfo(group.conversationId) != null }
),
pinnedIds = (friendList.map { it.userId } + groupList.map { it.conversationId }).reversed(),
)
)
},
modifier = Modifier

View File

@@ -135,13 +135,26 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
color = Color.White,
modifier = Modifier.padding(bottom = 4.dp)
)
Row(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "Eternal", subtitle = "", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "Kaladin", subtitle = "", imageRes = R.drawable.pfp_kaladin, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "schrodingerspet", subtitle = "", imageRes = R.drawable.pfp_schrodingerspet, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "RSR", subtitle = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
}
}
}

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
@@ -63,6 +64,8 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@@ -148,11 +151,15 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
label: String? = null,
contentDescription: String? = label,
shrinkFactor: Float = 1f,
modifier: Modifier = Modifier,
expandedWidth: Dp? = null,
collapsedWidth: Dp = 36.dp,
haptic: HapticFeedback,
onClick: () -> Unit,
) {
val targetWidth = expandedWidth?.let { lerp(collapsedWidth, it, shrinkFactor) }
Surface(
modifier = Modifier.height(36.dp).widthIn(min = 36.dp),
modifier = modifier.height(36.dp).then(if (targetWidth != null) Modifier.width(targetWidth) else Modifier),
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(
@@ -167,9 +174,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40))
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)),
.padding(vertical = 6.dp, horizontal = lerp(7.dp, 10.dp, shrinkFactor)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
@@ -182,17 +190,19 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
}
)
if (label != null) {
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f)
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
Text(
text = label,
color = Color.White.copy(alpha = labelAlpha),
fontSize = 12.sp, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = TextOverflow.Clip,
modifier = Modifier
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
.widthIn(max = (75 * shrinkFactor).dp)
)
val labelAlpha = ((shrinkFactor - 0.45f) / 0.55f).coerceIn(0f, 1f)
if (labelAlpha > 0.02f) {
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
Text(
text = label,
color = Color.White.copy(alpha = labelAlpha),
fontSize = 12.sp, fontWeight = FontWeight.Medium,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
.weight(1f, fill = false)
)
}
}
}
}
@@ -206,14 +216,23 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
val shrinkFactor by remember(scrollState.value) {
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
}
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.APHELION,
shrinkFactor = shrinkFactor,
modifier = Modifier.width(lerp(36.dp, 66.dp, shrinkFactor))
)
AphelionTopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"],
expandedWidth = 88.dp,
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.homeLogs.navigate() }
AphelionTopBarActionChip(
icon = Icons.Filled.Settings,
label = context.translation["manager.routes.home_settings"],
expandedWidth = 96.dp,
shrinkFactor = shrinkFactor, haptic = haptic
) { routes.settings.navigate() }
}
@@ -226,7 +245,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
downloadState: UpdateDownloader.DownloadState,
downloadProgress: Float,
onUpdateAction: () -> Unit,
channelLabel: String,
isPurrAuraActive: Boolean,
onAboutClick: () -> Unit,
avenirNext: FontFamily,
@@ -283,7 +301,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
HeroBadge(translation.format("hero_version_label", "version" to versionName, "channel" to channelLabel))
HeroBadge(translation.format("hero_version_label", "version" to versionName))
gitHashShort.takeIf { it.isNotBlank() && it.lowercase() != "unknown" }?.let {
HeroBadge(translation.format("hero_build_label", "build" to it))
}
@@ -444,17 +462,17 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
}
hasInitialized -> storedTiles
else -> {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
context.coroutineScope.launch(Dispatchers.IO) {
context.database.setQuickTiles(allQuickTileNames)
prefs.edit().putBoolean(HomeRootSection.QUICK_TILES_INITIALIZED_PREF, true).apply()
}
allQuickTileNames
}
}
}
val updateChannel = context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable"
val channelLabel = if (updateChannel == "prerelease") translation["channel_label_prerelease"] ?: "" else translation["channel_label_stable"] ?: ""
val latestUpdate by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(updateChannel)) {
Updater.getLatestRelease(if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE)
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
Updater.getLatestRelease(Channel.STABLE)
}
val downloadState by UpdateDownloader.downloadState.collectAsState()
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
@@ -502,7 +520,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
changelogLoading = true
changelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val url = changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
@@ -540,7 +558,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
fullChangelogLoading = true
fullChangelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
val url = changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
@@ -646,37 +664,27 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
.padding(horizontal = 16.dp)
.height(headerHeight)
) {
Text(
text = "PurrfectSnap",
color = Color.White.copy(alpha = stickyBrandingAlpha),
fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext,
modifier = Modifier.align(Alignment.Center)
)
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
modifier = Modifier
.align(Alignment.Center)
.wrapContentWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
AphelionTopBarActionChip(
icon = Icons.Filled.Notifications, label = null,
expandedWidth = 52.dp,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation["announcements_button_description"],
haptic = haptic
) { showAnnouncementsDialog = true; loadAnnouncements() }
AphelionTopBarActionChip(
icon = Icons.Filled.Description, label = null,
expandedWidth = 52.dp,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
haptic = haptic
) { showFullChangelogDialog = true; loadFullChangelog() }
}
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() },
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AphelionHomeActionChips(scrollState = scrollState, haptic = haptic)
}
}
@@ -692,7 +700,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
downloadState = downloadState,
downloadProgress = downloadProgress,
onUpdateAction = { latestUpdate?.let { showChangelogDialog = true; loadChangelog() } },
channelLabel = channelLabel,
isPurrAuraActive = isPurrAuraActive,
onAboutClick = { routes.about.navigate() },
avenirNext = avenirNext,

View File

@@ -47,15 +47,11 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
isRefreshing = true
coroutineScope.launch(Dispatchers.IO) {
val readerResult = runCatching {
context.log.newReader { line ->
if (shouldHideLog(line)) return@newReader
coroutineScope.launch(Dispatchers.Main) {
visibleLogs.add(line)
}
}
context.log.newReader { /* items are batch-added from reader logic below */ }
}
readerResult.onFailure {
context.longToast(translation["read_logs_failed_toast"] ?: "Failed to read logs")
withContext(Dispatchers.Main) { isRefreshing = false }
}
readerResult.getOrNull()?.let { reader ->
logReader = reader
@@ -78,52 +74,63 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) {
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()
PurrfectGlassCard(
title = translation["filter_logs_title"] ?: "Log Filters",
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f))
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
HomeLogs.LogCategory.entries.forEach { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
enabledCategories[category] = !(enabledCategories[category] ?: true)
refreshLogs()
}
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = enabledCategories[category] == true,
onCheckedChange = { checked ->
enabledCategories[category] = checked
refreshLogs()
},
colors = CheckboxDefaults.colors(
checkedColor = PurrfectPalette.glowPrimary,
uncheckedColor = Color.White.copy(alpha = 0.3f),
checkmarkColor = Color.White
)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = translation[category.translationKey] ?: category.name,
color = Color.White,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
)
}
.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")
}
Button(
onClick = { showFilterDialog = false },
modifier = Modifier.fillMaxWidth().height(54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
) {
Text(translation["filter_logs_done_button"] ?: "Apply Filters", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion
import android.content.SharedPreferences
import android.content.Intent
import com.google.gson.JsonParser
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -29,18 +30,31 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.InternalFileHandleType
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerConversationExportTarget
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
import me.eternal.purrfectsnap.storage.findFriend
import me.eternal.purrfectsnap.storage.getAllScopeNotes
import me.eternal.purrfectsnap.storage.getFriendInfo
import me.eternal.purrfectsnap.storage.getGroupInfo
import me.eternal.purrfectsnap.storage.setAllScopeNotes
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
@@ -60,6 +74,8 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.drawToBitmap
import java.io.File
import java.net.URLEncoder
import java.text.DateFormat
import java.util.Date
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -222,22 +238,12 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
RowTitle(title = translation["updates_title"])
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
var autoUpdateCheck by remember { mutableStateOf(context.config.root.global.updateSettings.autoUpdateCheck.getNullable() ?: true) }
var selectedChannel by remember { mutableStateOf(context.config.root.global.updateSettings.updateChannel.getNullable() ?: "stable") }
var channelMenuExpanded by remember { mutableStateOf(false) }
ShiftedRow {
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 55.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(text = translation["auto_update_check"], fontSize = 14.sp)
Switch(checked = autoUpdateCheck, onCheckedChange = { if (context.config.root.global.uiSettings.hapticFeedback.get()) hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress); autoUpdateCheck = it; context.config.root.global.updateSettings.autoUpdateCheck.set(it); context.config.writeConfig(); scheduleUpdateCheck() }, modifier = Modifier.padding(end = 26.dp), colors = purrfectSwitchColors())
}
}
AnimatedVisibility(visible = autoUpdateCheck) {
ExposedDropdownMenuBox(expanded = channelMenuExpanded, onExpandedChange = { channelMenuExpanded = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 26.dp)) {
AestheticDropdownField(value = translation.getOrNull("update_channel_${selectedChannel}") ?: selectedChannel, expanded = channelMenuExpanded, modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), onClick = { channelMenuExpanded = true })
ExposedDropdownMenu(expanded = channelMenuExpanded, onDismissRequest = { channelMenuExpanded = false }) {
listOf("stable", "prerelease").forEach { channel -> DropdownMenuItem(text = { Text(text = translation.getOrNull("update_channel_${channel}") ?: channel) }, onClick = { selectedChannel = channel; channelMenuExpanded = false; context.config.root.global.updateSettings.updateChannel.set(channel); context.config.writeConfig(); scheduleUpdateCheck() }) }
}
}
}
}
}
@@ -257,11 +263,494 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
var storedMessagesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredMessageCount() }
var storedStoriesCount by rememberAsyncMutableState(defaultValue = 0) { context.messageLogger.getStoredStoriesCount() }
var showImportDialog by remember { mutableStateOf(false) }
var showExportOptionsDialog by remember { mutableStateOf(false) }
var showConversationExportDialog by remember { mutableStateOf(false) }
var showConversationFormatDialog by remember { mutableStateOf(false) }
var conversationSearchQuery by remember { mutableStateOf("") }
var selectedConversationForExport by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
var pendingConversationExportTarget by remember { mutableStateOf<LoggerConversationExportTarget?>(null) }
val loggerHistoryTranslation = remember { context.translation.getCategory("logger_history") }
data class ConversationSearchTarget(
val target: LoggerConversationExportTarget,
val friendDisplayName: String?,
val friendUsername: String?,
val chatDisplayName: String?,
val groupDisplayName: String?,
val readableUsernames: List<String>,
val readableIdentifiers: List<String>,
val isDirectChat: Boolean,
val isGroupChat: Boolean,
val sortOrder: Int
)
data class ConversationExportFormat(
val extension: String,
val mimeType: String,
val label: String
)
data class ParsedConversationMessage(
val senderId: String,
val senderUsername: String,
val timestamp: Long,
val contentType: ContentType,
val messageText: String?,
val attachments: List<DecodedAttachment>
)
fun String.isUuidLike(): Boolean {
val value = trim()
if (value.length != 36) return false
if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') return false
return value.filterIndexed { index, _ ->
index != 8 && index != 13 && index != 18 && index != 23
}.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
}
fun String.isLikelyInternalId(): Boolean {
val value = trim()
if (value.isUuidLike()) return true
if (value.length >= 10 && value.all(Char::isDigit)) return true
if (value.length >= 16 && value.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' || it == '-' }) {
val digitCount = value.count(Char::isDigit)
val alphaCount = value.count { it.lowercaseChar() in 'a'..'f' }
if (digitCount >= 4 && alphaCount >= 4) return true
}
return false
}
fun String.toReadableIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.isLikelyInternalId()) return null
if (!value.any { it.isLetter() }) return null
return value
}
fun String.toSearchIdentityOrNull(): String? {
val value = trim()
if (value.isEmpty()) return null
if (value.equals("myai", ignoreCase = true)) return null
return value
}
val exportTargets by rememberAsyncMutableState(defaultValue = emptyList<LoggerConversationExportTarget>()) {
context.messageLogger.getConversationExportTargets()
}
val exportSearchTargets by rememberAsyncMutableState(
defaultValue = emptyList<ConversationSearchTarget>(),
keys = arrayOf(exportTargets)
) {
val friendIdentityCache = mutableMapOf<String, Pair<String?, String?>?>()
exportTargets.mapIndexedNotNull { index, target ->
val friend = context.database.findFriend(target.conversationId)
val group = context.database.getGroupInfo(target.conversationId)
val chatDisplayName = target.groupTitle
?.toReadableIdentityOrNull()
?.takeIf { !it.equals(target.conversationId, ignoreCase = true) }
val friendDisplayName = friend?.displayName?.toReadableIdentityOrNull()
val friendUsername = friend?.mutableUsername?.toReadableIdentityOrNull()
val searchableUsernames = target.usernames
.mapNotNull { it.toSearchIdentityOrNull() }
.distinct()
val readableUsernames = searchableUsernames
.mapNotNull { it.toReadableIdentityOrNull() }
.distinct()
val hasManyParticipants = target.userIds.distinct().size > 2 || searchableUsernames.size > 2
val fallbackFriendIdentities = if (friend == null && !hasManyParticipants) {
target.userIds.mapNotNull { userId ->
friendIdentityCache.getOrPut(userId) {
context.database.getFriendInfo(userId)?.let {
it.displayName?.toReadableIdentityOrNull() to
it.mutableUsername.toReadableIdentityOrNull()
}
}?.takeIf { it.first != null || it.second != null }
}
} else {
emptyList()
}
val fallbackFriendDisplayName = fallbackFriendIdentities.firstNotNullOfOrNull { it.first }
val fallbackFriendUsername = fallbackFriendIdentities.firstNotNullOfOrNull { it.second }
val resolvedFriendDisplayName = friendDisplayName ?: fallbackFriendDisplayName
val resolvedFriendUsername = friendUsername ?: fallbackFriendUsername
val groupDisplayName = group?.name?.toReadableIdentityOrNull()
?: chatDisplayName?.takeIf { hasManyParticipants }
val isGroupChat = groupDisplayName != null || hasManyParticipants
val isDirectChat = !isGroupChat
val readableIdentifiers = buildList {
add(target.conversationId)
addAll(target.userIds)
resolvedFriendDisplayName?.let { add(it) }
resolvedFriendUsername?.let { add(it) }
chatDisplayName?.let { add(it) }
groupDisplayName?.let { add(it) }
addAll(searchableUsernames)
addAll(readableUsernames)
}.distinct()
ConversationSearchTarget(
target = target,
friendDisplayName = resolvedFriendDisplayName,
friendUsername = resolvedFriendUsername,
chatDisplayName = chatDisplayName,
groupDisplayName = groupDisplayName,
readableUsernames = readableUsernames,
readableIdentifiers = readableIdentifiers,
isDirectChat = isDirectChat,
isGroupChat = isGroupChat,
sortOrder = index
)
}.sortedWith(
compareBy<ConversationSearchTarget> {
when {
it.isDirectChat -> 0
it.isGroupChat -> 1
else -> 2
}
}.thenBy { it.sortOrder }
)
}
val filteredExportTargets = remember(exportSearchTargets, conversationSearchQuery) {
val query = conversationSearchQuery.trim()
if (query.isBlank()) {
exportSearchTargets
} else {
exportSearchTargets.filter { searchTarget ->
searchTarget.readableIdentifiers.any {
it.contains(query, ignoreCase = true)
}
}
}
}
val exportFormats = remember {
listOf(
ConversationExportFormat("db", "application/octet-stream", ".db"),
ConversationExportFormat("html", "text/html", "HTML"),
ConversationExportFormat("txt", "text/plain", "TXT")
)
}
fun formatExportTarget(searchTarget: ConversationSearchTarget): String {
searchTarget.friendDisplayName?.let { displayName ->
val username = searchTarget.friendUsername
val formattedName = if (username != null && !username.equals(displayName, ignoreCase = true)) {
"$displayName • @$username"
} else {
displayName
}
return loggerHistoryTranslation.format("list_friend_format", "name" to formattedName)
}
searchTarget.friendUsername?.let { username ->
return loggerHistoryTranslation.format("list_friend_format", "name" to "@$username")
}
searchTarget.chatDisplayName?.takeIf { searchTarget.isDirectChat }?.let {
return loggerHistoryTranslation.format("list_friend_format", "name" to it)
}
if (searchTarget.isDirectChat && searchTarget.readableUsernames.isNotEmpty()) {
val friendName = if (searchTarget.readableUsernames.size == 1) {
searchTarget.readableUsernames.first()
} else {
searchTarget.readableUsernames.joinToString(", ")
}
return loggerHistoryTranslation.format("list_friend_format", "name" to friendName)
}
searchTarget.groupDisplayName?.let {
return loggerHistoryTranslation.format("list_group_format", "name" to it)
}
if (searchTarget.readableUsernames.isNotEmpty()) {
return loggerHistoryTranslation.format(
"list_group_format",
"name" to searchTarget.readableUsernames.joinToString(", ")
)
}
return if (searchTarget.isGroupChat) {
loggerHistoryTranslation.format("list_group_format", "name" to searchTarget.target.conversationId)
} else {
loggerHistoryTranslation.format("list_friend_format", "name" to searchTarget.target.conversationId)
}
}
fun showExportError(throwable: Throwable) {
context.log.error("Failed to export message logger", throwable)
context.shortToast(
translation.format(
"message_logger_export_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun showImportError(throwable: Throwable) {
context.log.error("Failed to import message logger", throwable)
context.shortToast(
translation.format(
"import_failed_toast",
"message" to (throwable.message ?: "Unknown error")
)
)
}
fun parseConversationMessage(message: LoggedMessage): ParsedConversationMessage {
val messageObject = runCatching {
JsonParser.parseString(String(message.messageData, Charsets.UTF_8)).asJsonObject
}.getOrNull()
val messageContent = messageObject?.getAsJsonObject("mMessageContent")
val contentBytes = runCatching {
messageContent?.getAsJsonArray("mContent")?.map { it.asByte }?.toByteArray()
}.getOrNull()
val contentType = messageContent?.getAsJsonPrimitive("mContentType")?.asString?.let {
runCatching { ContentType.valueOf(it) }.getOrNull()
} ?: contentBytes?.let { ContentType.fromMessageContainer(ProtoReader(it)) } ?: ContentType.UNKNOWN
val messageText = contentBytes?.getMessageText(contentType)
val attachments = runCatching {
messageContent?.let { MessageDecoder.decode(it) } ?: emptyList()
}.getOrDefault(emptyList())
return ParsedConversationMessage(
senderId = message.userId,
senderUsername = message.username,
timestamp = message.sendTimestamp,
contentType = contentType,
messageText = messageText,
attachments = attachments
)
}
fun htmlEscape(input: String): String {
val escaped = StringBuilder(input.length)
input.forEach { char ->
when (char) {
'&' -> escaped.append("&amp;")
'<' -> escaped.append("&lt;")
'>' -> escaped.append("&gt;")
'"' -> escaped.append("&quot;")
'\'' -> escaped.append("&#39;")
else -> escaped.append(char)
}
}
return escaped.toString()
}
fun writeConversationExportFile(
target: LoggerConversationExportTarget,
format: ConversationExportFormat,
outputFile: File
): Int {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
throw IllegalArgumentException("Conversation ID cannot be empty")
}
val searchTarget = exportSearchTargets.firstOrNull { it.target.conversationId == conversationId }
val conversationTitle = searchTarget?.let { formatExportTarget(it) }
?: (translation["message_logger_export_individual_chat"] ?: "Exported Chat")
val dateFormatter = DateFormat.getDateTimeInstance()
val senderCache = mutableMapOf<String, String>()
fun formatSenderLabel(senderId: String, senderUsername: String): String {
val friendInfo = context.database.getFriendInfo(senderId)
val senderDisplayName = friendInfo?.displayName?.toReadableIdentityOrNull()
val senderReadableUsername = friendInfo?.mutableUsername?.toReadableIdentityOrNull()
?: senderUsername.toReadableIdentityOrNull()
return when {
senderDisplayName != null &&
senderReadableUsername != null &&
!senderDisplayName.equals(senderReadableUsername, ignoreCase = true) ->
"$senderDisplayName (@$senderReadableUsername)"
senderDisplayName != null -> senderDisplayName
senderReadableUsername != null -> "@$senderReadableUsername"
else -> translation["sender_unknown"] ?: "Unknown sender"
}
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Failed to prepare export file")
}
return outputFile.bufferedWriter(Charsets.UTF_8).use { writer ->
val isHtmlFormat = format.extension == "html"
if (isHtmlFormat) {
writer.appendLine("<!DOCTYPE html>")
writer.appendLine("<html><head><meta charset=\"UTF-8\" />")
writer.appendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />")
writer.appendLine("<title>${htmlEscape(conversationTitle)}</title>")
writer.appendLine(
"<style>body{font-family:Arial,sans-serif;background:#121212;color:#f3f3f3;padding:16px;}h2{margin-top:0;}" +
".meta{color:#a5a5a5;font-size:12px;margin-bottom:4px;}" +
".message{border:1px solid #2d2d2d;border-radius:10px;padding:10px;margin:10px 0;background:#1b1b1b;}" +
".content{white-space:pre-wrap;word-break:break-word;}" +
".attachments{margin:8px 0 0 18px;padding:0;}a{color:#8db7ff;}</style>"
)
writer.appendLine("</head><body>")
writer.appendLine("<h2>${htmlEscape(conversationTitle)}</h2>")
writer.appendLine("<p>${htmlEscape(translation.format("message_logger_conversation_id", "id" to conversationId))}</p>")
} else {
writer.appendLine(conversationTitle)
writer.appendLine("")
}
val exportedMessageCount = context.messageLogger.forEachConversationMessage(
conversationId = conversationId,
userIds = target.userIds,
orderAscending = true
) { loggedMessage ->
val parsed = parseConversationMessage(loggedMessage)
val senderInfo = senderCache.getOrPut(parsed.senderId) {
formatSenderLabel(parsed.senderId, parsed.senderUsername)
}
val senderLabel = senderInfo
val content = parsed.messageText?.takeIf { it.isNotBlank() } ?: if (parsed.contentType == ContentType.CHAT) {
loggerHistoryTranslation["empty_message"]
} else {
parsed.contentType.name.lowercase()
}
if (isHtmlFormat) {
writer.appendLine("<div class=\"message\">")
writer.appendLine(
"<div class=\"meta\">${
htmlEscape(
"${dateFormatter.format(Date(parsed.timestamp))} • $senderLabel • ${
parsed.contentType.name.lowercase()
}"
)
}</div>"
)
writer.appendLine("<div class=\"content\">${htmlEscape(content).replace("\n", "<br/>")}</div>")
if (parsed.attachments.isNotEmpty()) {
writer.appendLine("<ul class=\"attachments\">")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val directUrl = attachment.directUrl?.takeIf { it.isNotBlank() }
if (directUrl != null) {
writer.appendLine(
"<li><a href=\"${htmlEscape(directUrl)}\" target=\"_blank\" rel=\"noopener noreferrer\">${
htmlEscape(attachmentLabel)
}</a></li>"
)
} else {
val placeholder = attachment.boltKey?.takeIf { it.isNotBlank() }
?: attachment.mediaUniqueId?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine("<li>${htmlEscape("$attachmentLabel: $placeholder")}</li>")
}
}
writer.appendLine("</ul>")
}
writer.appendLine("</div>")
} else {
writer.appendLine("[${dateFormatter.format(Date(parsed.timestamp))}] $senderLabel: $content")
parsed.attachments.forEachIndexed { index, attachment ->
val attachmentLabel = "${loggerHistoryTranslation.format("chat_attachment", "index" to (index + 1).toString())} [${attachment.type.name.lowercase()}]"
val attachmentValue = attachment.directUrl?.takeIf { it.isNotBlank() }
?: (translation["message_logger_missing_attachment_placeholder"] ?: "Attachment unavailable")
writer.appendLine(" - $attachmentLabel: $attachmentValue")
}
writer.appendLine("")
}
}
if (exportedMessageCount == 0) {
if (isHtmlFormat) {
writer.appendLine("<p>${htmlEscape(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")}</p>")
} else {
writer.appendLine(translation["message_logger_no_messages_export_text"] ?: "No messages found in this chat.")
}
}
if (isHtmlFormat) {
writer.appendLine("</body></html>")
}
exportedMessageCount
}
}
fun exportFullDatabase() {
runCatching {
activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri ->
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out ->
context.messageLogger.databaseFile.inputStream().use { input -> input.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream")
}
}.onFailure { showExportError(it) }
}
fun exportConversation(target: LoggerConversationExportTarget, format: ConversationExportFormat) {
val conversationId = target.conversationId.trim()
if (conversationId.isEmpty()) {
context.shortToast(translation["message_logger_missing_conversation_toast"])
return
}
val fileNameSuffix = conversationId
.filter { it.isLetterOrDigit() || it == '-' || it == '_' }
.take(24)
.ifBlank { "chat" }
runCatching {
activityLauncherHelper.saveFile("message_logger_${fileNameSuffix}.${format.extension}", format.mimeType) { uri ->
scope.launch {
runCatching {
val exportedMessageCount = withContext(Dispatchers.IO) {
val tempFile = File(
context.androidContext.cacheDir,
"message_logger_export_${System.currentTimeMillis()}.${format.extension}"
)
try {
val messageCount = if (format.extension == "db") {
context.messageLogger.exportConversationDatabase(
outputFile = tempFile,
conversationId = conversationId,
userIds = target.userIds
).messageCount
} else {
writeConversationExportFile(target, format, tempFile)
}
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { output ->
tempFile.inputStream().use { input -> input.copyTo(output) }
} ?: throw IllegalStateException("Failed to open output stream")
messageCount
} finally {
tempFile.delete()
}
}
if (exportedMessageCount == 0) {
context.shortToast(translation["message_logger_empty_chat_toast"])
} else {
context.shortToast(translation["success_toast"])
}
}.onFailure { showExportError(it) }
}
}
}.onFailure { showExportError(it) }
}
fun dismissConversationExportDialog() {
showConversationExportDialog = false
selectedConversationForExport = null
conversationSearchQuery = ""
}
fun dismissConversationFormatDialog() {
showConversationFormatDialog = false
pendingConversationExportTarget = null
}
Column(modifier = Modifier.fillMaxWidth().padding(5.dp), verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
val summary = translation.format("message_logger_summary", "messageCount" to storedMessagesCount.toString(), "storyCount" to storedStoriesCount.toString()).replace("\n", " | ")
Text(summary, maxLines = 2, color = Color.White, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(10.dp) ) {
Button(onClick = { runCatching { activityLauncherHelper.saveFile("message_logger.db", "application/octet-stream") { uri -> context.androidContext.contentResolver.openOutputStream(uri.toUri())?.use { out -> context.messageLogger.databaseFile.inputStream().use { it.copyTo(out) } } } }.onFailure { context.log.error("Failed to export", it) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { showExportOptionsDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["export_button"]) }
Button(onClick = { runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> val tempFile = File(context.androidContext.cacheDir, "view_logger.db"); context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { it.copyTo(tempFile.outputStream()) }; routes.viewLoggerHistory.navigate { put("uri", URLEncoder.encode(tempFile.toUri().toString(), "UTF-8")) } } } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["view_button"]) }
Button(onClick = { runCatching { context.messageLogger.purgeAll(); storedMessagesCount = 0; storedStoriesCount = 0 }.onSuccess { context.shortToast(translation["success_toast"]) } }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["clear_button"]) }
Button(onClick = { showImportDialog = true }, colors = sharedButtonColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))) { Text(text = translation["import_button"]) }
@@ -269,7 +758,222 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
}
OutlinedButton(modifier = Modifier.fillMaxWidth().padding(5.dp), onClick = { routes.loggerHistory.navigate() }, colors = sharedOutlinedColors, border = BorderStroke(1.dp, Color.White.copy(alpha = 0.2f))) { Text(translation["view_logger_history_button"]) }
if (showImportDialog) {
AestheticDialog(onDismissRequest = { showImportDialog = false }, title = translation["message_logger_import_title"], text = translation["message_logger_import_text"], icon = Icons.Filled.Info, confirmButtonText = context.translation["button.import"], dismissButtonText = context.translation["button.cancel"], onConfirm = { showImportDialog = false; runCatching { activityLauncherHelper.openFile("application/octet-stream") { uri -> context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { context.messageLogger.databaseFile.outputStream().use { out -> it.copyTo(out) } }; storedMessagesCount = context.messageLogger.getStoredMessageCount(); storedStoriesCount = context.messageLogger.getStoredStoriesCount(); context.shortToast(translation["success_toast"]) } } }, onDismiss = { showImportDialog = false }, showCloseButton = false)
AestheticDialog(
onDismissRequest = { showImportDialog = false },
title = translation["message_logger_import_title"],
text = translation["message_logger_import_text"],
icon = Icons.Filled.Info,
confirmButtonText = context.translation["button.import"],
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
showImportDialog = false
runCatching {
activityLauncherHelper.openFile("application/octet-stream") { uri ->
scope.launch {
runCatching {
val importResult = withContext(Dispatchers.IO) {
context.androidContext.contentResolver.openInputStream(uri.toUri())?.use { input ->
context.messageLogger.importDatabase(input)
} ?: throw IllegalStateException("Failed to open selected backup file")
}
storedMessagesCount = importResult.messageCount
storedStoriesCount = importResult.storyCount
context.shortToast(translation["success_toast"])
}.onFailure { showImportError(it) }
}
}
}.onFailure { showImportError(it) }
},
onDismiss = { showImportDialog = false },
showCloseButton = false
)
}
if (showExportOptionsDialog) {
AestheticDialog(
onDismissRequest = { showExportOptionsDialog = false },
title = translation["message_logger_export_title"] ?: "Export Message Logger",
text = translation["message_logger_export_text"] ?: "Choose what to export.",
icon = Icons.Filled.SaveAlt,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { showExportOptionsDialog = false },
showCloseButton = false,
customContent = {
Button(
onClick = {
showExportOptionsDialog = false
pendingConversationExportTarget = null
showConversationExportDialog = true
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_individual_chat"] ?: "Export Individual Chat")
}
Button(
onClick = {
showExportOptionsDialog = false
exportFullDatabase()
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(translation["message_logger_export_full_database"] ?: "Export Full Database")
}
}
)
}
if (showConversationExportDialog) {
AestheticDialog(
onDismissRequest = { dismissConversationExportDialog() },
title = translation["message_logger_select_chat_title"] ?: "Export Individual Chat",
text = translation["message_logger_select_chat_text"] ?: "Search by username, display name, or chat name.",
icon = Icons.Filled.Search,
confirmButtonText = translation["message_logger_continue_button"] ?: "Continue",
dismissButtonText = context.translation["button.cancel"],
onConfirm = {
val selectedTarget = selectedConversationForExport ?: return@AestheticDialog
pendingConversationExportTarget = selectedTarget
dismissConversationExportDialog()
showConversationFormatDialog = true
},
onDismiss = { dismissConversationExportDialog() },
showCloseButton = false,
confirmEnabled = selectedConversationForExport != null,
customContent = {
OutlinedTextField(
value = conversationSearchQuery,
onValueChange = { conversationSearchQuery = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(context.translation["manager.dialogs.add_friend.search_hint"] ?: "Search")
},
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
trailingIcon = if (conversationSearchQuery.isNotBlank()) {
{
IconButton(onClick = { conversationSearchQuery = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null
)
}
}
} else null,
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.06f),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
if (filteredExportTargets.isEmpty()) {
Text(
text = translation["message_logger_no_chats_found"] ?: "No chats found",
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 280.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(filteredExportTargets.size) { index ->
val searchTarget = filteredExportTargets[index]
val target = searchTarget.target
val isSelected = selectedConversationForExport?.conversationId == searchTarget.target.conversationId
OutlinedButton(
onClick = { selectedConversationForExport = searchTarget.target },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White),
border = BorderStroke(
1.dp,
if (isSelected) {
PurrfectPalette.glowPrimary.copy(alpha = 0.55f)
} else {
Color.White.copy(alpha = 0.18f)
}
)
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = formatExportTarget(searchTarget),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val secondaryLabel = when {
searchTarget.friendDisplayName != null && searchTarget.friendUsername != null -> "@${searchTarget.friendUsername}"
searchTarget.friendDisplayName != null -> searchTarget.friendDisplayName
searchTarget.chatDisplayName != null -> searchTarget.chatDisplayName
searchTarget.groupDisplayName != null -> searchTarget.groupDisplayName
searchTarget.readableUsernames.isNotEmpty() -> searchTarget.readableUsernames.joinToString(", ")
else -> null
}
if (secondaryLabel != null) {
Text(
text = secondaryLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = translation.format("message_logger_message_count", "count" to target.messageCount.toString()),
color = PurrfectPalette.textSecondary,
fontSize = 12.sp
)
}
}
}
}
}
}
)
}
if (showConversationFormatDialog && pendingConversationExportTarget != null) {
AestheticDialog(
onDismissRequest = { dismissConversationFormatDialog() },
title = translation["message_logger_select_export_format_title"] ?: "Select Export Format",
text = translation["message_logger_select_export_format_text"] ?: "Choose how to export the selected chat.",
icon = Icons.Filled.Description,
confirmButtonText = context.translation["button.cancel"],
onConfirm = { dismissConversationFormatDialog() },
showCloseButton = false,
customContent = {
exportFormats.forEach { format ->
val formatLabel = when (format.extension) {
"db" -> translation["message_logger_export_format_db"] ?: ".db"
"html" -> translation["message_logger_export_format_html"] ?: "HTML"
else -> translation["message_logger_export_format_txt"] ?: "TXT"
}
Button(
onClick = {
val exportTarget = pendingConversationExportTarget ?: return@Button
dismissConversationFormatDialog()
exportConversation(exportTarget, format)
},
modifier = Modifier.fillMaxWidth(),
colors = sharedButtonColors,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Text(formatLabel)
}
}
}
)
}
}
}

View File

@@ -39,15 +39,20 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.ui.manager.pages.social.SocialRootSection
import me.eternal.purrfectsnap.ui.manager.pages.social.sortSocialFriends
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
// Controller handles data loading and synchronization
SocialDataController()
val titles = remember {
listOf(translation["friends_tab"], translation["groups_tab"])
}
@@ -56,19 +61,9 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.database.receiveMessagingDataCallback = { friends, groups ->
friendList = friends
groupList = groups
}
updateScopeLists()
}
DisposableEffect(Unit) {
onDispose {
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
// Filter logic based on the parent's synchronized data lists
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {
friendList

View File

@@ -269,8 +269,9 @@ class ManageFriendTrackerReposSection: Routes.Route() {
}
override val content: @Composable (androidx.navigation.NavBackStackEntry) -> Unit = {
val repositories by remember(refreshTrigger.value) {
mutableStateOf<List<String>>(runBlocking { context.database.getRepositories("friend_tracker") })
var repositories by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(refreshTrigger.value) {
repositories = context.database.getRepositories("friend_tracker")
}
val density = LocalDensity.current
val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()

View File

@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
@@ -93,6 +94,8 @@ import androidx.navigation.compose.rememberNavController
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -104,6 +107,8 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.IntroShowcaseScreen
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlinx.coroutines.delay
@@ -127,6 +132,9 @@ class SetupActivity : ComponentActivity() {
}
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
val setupPrefs = setupContext.sharedPreferences
val setupRoutes = Routes(setupContext).apply {
activityLauncher = ActivityLauncherHelper(this@SetupActivity)
}
fun hasRequirement(requirement: Int) = requirements and requirement == requirement
val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false)
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
@@ -159,6 +167,7 @@ class SetupActivity : ComponentActivity() {
val requiredScreens = mutableListOf<SetupScreen>().apply {
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
add(IntroShowcaseScreen().apply { route = "introShowcase" })
add(PickLanguageScreen().apply { route = "language" })
if (isFirstRunFlow) {
add(InstallModeScreen(
@@ -315,19 +324,7 @@ class SetupActivity : ComponentActivity() {
AppMaterialTheme {
val view = LocalView.current
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
var showImportantDialog by rememberSaveable {
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
}
var importantTimeout by remember { mutableIntStateOf(5) }
LaunchedEffect(showImportantDialog) {
if (showImportantDialog) {
importantTimeout = 5
while (importantTimeout > 0) {
delay(1000)
importantTimeout--
}
}
}
var setupAiPrompt by rememberSaveable { mutableStateOf<String?>(null) }
SideEffect {
val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -344,46 +341,8 @@ class SetupActivity : ComponentActivity() {
.fillMaxSize()
.background(Color.Transparent)
) {
if (showImportantDialog) {
val confirmLabel = if (importantTimeout > 0) {
translation.format(
"setup.activity.important_confirm_timeout",
"seconds" to importantTimeout.toString()
)
} else {
translation["setup.activity.important_confirm"]
}
AestheticDialog(
onDismissRequest = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
title = translation["setup.activity.important_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
confirmEnabled = importantTimeout == 0,
showCloseButton = false,
customContent = {
Text(
text = translation["setup.activity.important_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
},
opaque = true
)
}
SetupAuroraBackground()
SetupTopBar()
SetupTopBar(onAskAi = { setupAiPrompt = "hi" })
val bottomPadding = 118.dp + navBarPadding
Column(
modifier = Modifier
@@ -468,6 +427,14 @@ class SetupActivity : ComponentActivity() {
.navigationBarsPadding()
.padding(bottom = 32.dp)
)
setupAiPrompt?.let { prompt ->
ManagerAssistantDialog(
context = setupContext,
routes = setupRoutes,
initialUserMessage = prompt,
onDismiss = { setupAiPrompt = null }
)
}
}
}
}
@@ -483,6 +450,12 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
subtitle = translation["setup.activity.language_subtitle"],
icon = Icons.Filled.Language
)
is IntroShowcaseScreen -> SetupStepMeta(
route = route,
title = "Welcome",
subtitle = "Preview what PurrfectSnap can do",
icon = Icons.Filled.AutoAwesome
)
is InstallModeScreen -> SetupStepMeta(
route = route,
@@ -587,7 +560,7 @@ private fun SetupAuroraBackground() {
}
@Composable
private fun SetupTopBar() {
private fun SetupTopBar(onAskAi: () -> Unit) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Surface(
modifier = Modifier
@@ -613,14 +586,37 @@ private fun SetupTopBar() {
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "PurrfectSnap",
color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
fontSize = 18.sp,
modifier = Modifier.weight(1f)
)
Surface(
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.08f),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(40))
.clickable(onClick = onAskAi)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Color.White)
Text(
text = "Ask AI",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
}
}
}
}
}

View File

@@ -0,0 +1,112 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
class IntroShowcaseScreen : SetupScreen() {
private val slides = listOf(
R.drawable.setup_slide_plus to "Unlock Snapchat Plus for free!",
R.drawable.setup_slide_upload_tag to "Bypass the Media Upload tag!",
R.drawable.setup_slide_downloads to "Download Snaps, & Spotlights!"
)
@Composable
override fun Content() {
LaunchedEffect(Unit) { allowNext(true) }
var currentIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(5000)
currentIndex = (currentIndex + 1) % slides.size
}
}
SetupCard {
StepTitle(
title = "Welcome to PurrfectSnap",
subtitle = "A quick look before setup begins",
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
AnimatedContent(targetState = currentIndex, label = "setupShowcase") { index ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
shape = RoundedCornerShape(24.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Image(
painter = painterResource(slides[index].first),
contentDescription = slides[index].second,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))
)
}
Text(
text = slides[index].second,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
slides.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(if (index == currentIndex) 10.dp else 8.dp)
.clip(CircleShape)
.background(if (index == currentIndex) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.3f))
)
}
}
}
}
}

View File

@@ -74,6 +74,8 @@ import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
import me.eternal.purrfectsnap.setup.patch.LSPatch
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -111,6 +113,10 @@ class PatchSnapchatScreen : SetupScreen() {
var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) }
var showIssuesDialog by remember { mutableStateOf(false) }
val assistantRoutes = remember {
Routes(context).apply {
}
}
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
initialValue = 0f,
targetValue = 1f,
@@ -313,62 +319,12 @@ class PatchSnapchatScreen : SetupScreen() {
}
if (showIssuesDialog) {
AestheticDialog(
onDismissRequest = { showIssuesDialog = false },
title = translation["setup.patch.issues_title"],
text = "",
icon = Icons.Filled.Info,
confirmButtonText = translation["setup.patch.issues_confirm"],
onConfirm = { showIssuesDialog = false },
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = translation["setup.patch.issues_heading"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.patch.issues_conflict_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_conflict_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_adb_command"],
style = bodyStyle,
textAlign = TextAlign.Start,
softWrap = false,
modifier = Modifier.horizontalScroll(rememberScrollState())
)
Text(
text = translation["setup.patch.issues_invalid_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_invalid_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
}
}
ManagerAssistantDialog(
context = context,
routes = assistantRoutes,
initialUserMessage = "I am facing an App not installed issue or Package appears to be invalid issue while installing Snapchat. How do I fix it?",
showImprovementLogging = false,
onDismiss = { showIssuesDialog = false }
)
}

View File

@@ -431,9 +431,10 @@ class AlertDialogs(
DefaultDialogCard {
var fieldValue by remember {
mutableStateOf(property.value.get().toString().let {
val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it
TextFieldValue(
text = it,
selection = TextRange(it.length)
text = t,
selection = TextRange(t.length)
)
})
}
@@ -447,10 +448,21 @@ class AlertDialogs(
}
.focusRequester(focusRequester),
value = fieldValue,
onValueChange = { fieldValue = it },
keyboardOptions = when (property.key.dataType.type) {
DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
onValueChange = { newVal ->
fieldValue = if (property.key.params.digitsOnlyInput) {
val filtered = newVal.text.filter { ch -> ch.isDigit() }
if (newVal.text != filtered) {
Toast.makeText(context, translation["manager.sections.features.digits_only_toast"], Toast.LENGTH_SHORT).show()
}
newVal.copy(text = filtered)
} else {
newVal
}
},
keyboardOptions = when {
property.key.params.digitsOnlyInput -> KeyboardOptions(keyboardType = KeyboardType.Number)
property.key.dataType.type == DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number)
property.key.dataType.type == DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal)
else -> KeyboardOptions(keyboardType = KeyboardType.Text)
},
singleLine = true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

View File

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

View File

@@ -1,4 +1,5 @@
## v1.1.0
- Fix: Download Context Menu now resolves Message Logger deleted media when available.
- Fix: message preview overlap in French.
- Fix: transparent gallery media send override dialog.
- Fix: message logger autopurge "Never" no longer resets to 3 days.

View File

@@ -1,3 +1,43 @@
## v1.7.1
- Auto-Open Engine ghost notification fix.
- Continous Send notifiction bug fix.
- Disappering chats fix.
- Social page automatic selection bug fix.
## v1.7.0
- Features:
- Implemented "PurrfectSnap AI" (tq ΞTΞRNAL)
- Implemented app intro showcase (tq ΞTΞRNAL)
- Implemented "Spoof follower count" (tq RSR)
- Implemented Social Tab sorting by Streak Length (tq Javalsta)
- Implemented "Chat Hold Kill" (tq SUJΛL)
- Implemented "Snapchat plus purchase date spoof" (tq SUJΛL)
- Implemented Message log export for individual chat (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Implemented Memory message icon indicator(tq RSR)
- Implemented two new message indicator toggles, for self snaps and group.
- Implemented new toggles for chat and snap stealth mode in friend feed menu.
- Implemented new notification card for Continous send feature.
- Fixes:
- Improved message icon indicator reliability and redesigned all chat status indicators. (tq RSR)
- Media resend flow bug fixes. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Message Logger backup import bug fixes and implemented logging to report success or failure. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Media download support through message logger. (tq 𝚜𝚌𝚑𝚛𝚘𝚍𝚒𝚗𝚐𝚎𝚛𝚜𝚙𝚎𝚝)
- Snapchat Plus bug fixes to improve stability.
- Spoof Device profile Backup/Restore bug fixes.
- Redesgined manager app Logs filtering UI.
- Media Downloader stability and bug fixes.
- Performance Mode: Fixed Max Performance mode persistence after app restart.
- Chat Preview: Realignment of the chat preview layout.
- Update Checker: Refactored the app update checker to follow a single, stable release track with automated daily checks.
- Auto-Open engine: Expanded Notification UI has been refactored and redundant stats has been removed.
- Social tab: Converted the rules configuration page to a Lazy Column architecture. This eliminates UI freezes when managing large friend lists.
- Chat feed scroll stutter: Implemented a thread-safe memory cache for chat suppression logic. This instant memory check allows Snapchat chat feed to scroll at 100% native speed.
- GPU Offloading: Forced hardware-layer acceleration for all camera and video preview views to reduce CPU thermal load and power consumption.
- Refactored all runBlocking calls and main-thread database queries across 12 major pages to eliminate the UI freezes/stutters and the scrolling, navigation, and page transitions are now locked at high frame rates.
- Migrated heavy bridge data processing (JSON parsing and DB writes) to background IO coroutines, preventing ANR (App Not Responding) crashes during large data synchronizations.
## 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)

View File

@@ -71,7 +71,7 @@ interface BridgeInterface {
* @param groups list of groups (MessagingGroupInfo as parcelable)
* @param friends list of friends (MessagingFriendInfo as parcelable)
*/
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends);
oneway void passGroupsAndFriends(in List<String> groups, in List<String> friends, int chunkIndex, int totalChunks);
@nullable String getScopeNotes(String id);

File diff suppressed because it is too large Load Diff

View File

@@ -270,6 +270,26 @@
"success_toast": "Done!",
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
"export_button": "Export",
"message_logger_export_title": "Export Message Logger",
"message_logger_export_text": "Choose what to export.",
"message_logger_export_individual_chat": "Export Individual Chat",
"message_logger_export_full_database": "Export Full Database",
"message_logger_select_chat_title": "Export Individual Chat",
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
"message_logger_continue_button": "Continue",
"message_logger_select_export_format_title": "Select Export Format",
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
"message_logger_export_format_db": ".db",
"message_logger_export_format_html": "HTML",
"message_logger_export_format_txt": "TXT",
"message_logger_no_chats_found": "No chats found",
"message_logger_no_messages_export_text": "No messages found in this chat.",
"message_logger_conversation_id": "Conversation ID: {id}",
"message_logger_message_count": "{count} messages",
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
"message_logger_export_failed_toast": "Export failed: {message}",
"message_logger_missing_conversation_toast": "Missing conversation ID",
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
"import_button": "Import",
"clear_button": "Clear",
"view_logger_history_button": "View Logger History",
@@ -1105,6 +1125,10 @@
"name": "Show Streak Expiration Info",
"description": "Shows a Streak Expiration timer next to the Streaks counter"
},
"sort_social_tab_by_streak_length": {
"name": "Sort Social Tab by Streak Length",
"description": "Shows friends with streaks first, ordered from the longest streak to the shortest in the social tab and friend picker"
},
"hide_friend_feed_entry": {
"name": "Hide Friend Feed Entry",
"description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature"
@@ -2314,6 +2338,8 @@
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
"stealth": "\ud83d\udc7b Full Stealth Mode",
"snap_stealth": "\ud83d\udcf7 Snap Stealth Mode",
"chat_stealth": "\ud83d\udcac Chat 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",
@@ -2641,7 +2667,10 @@
"platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)",
"location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled",
"ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor",
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps"
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps",
"memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera",
"skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64",
"disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65"
},
"auto_mark_as_read": {
"conversation_read": "Mark conversation as read when sending a message",

View File

@@ -197,12 +197,12 @@
},
"sections": {
"home": {
"version_title": "v{versionName} \u00b7 by ΞTΞRNAL",
"version_title": "v{versionName} \u00b7 by \u039eT\u039eRNAL",
"update_title": "PurrfectSnap Update",
"update_content": "Version {version} is available!",
"update_button": "Download",
"hero_tagline": "An Xposed Module meant to enhance your Snapchat experience",
"hero_version_label": "Version: {version} - {channel}",
"hero_version_label": "Version: {version}",
"hero_build_label": "Build: {build}",
"update_ready_label": "Ready to install",
"purr_aura_active_label": "PurrAura Active!",
@@ -247,9 +247,9 @@
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by \u039eT\u039eRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!",
"about_magic_toast": "Tap 5 times in this screen to see some magic \ud83d\ude09!",
"github_button": "GitHub",
"telegram_button": "Telegram"
},
@@ -281,6 +281,26 @@
"success_toast": "Done!",
"message_logger_summary": "{messageCount} messages\n{storyCount} stories",
"export_button": "Export",
"message_logger_export_title": "Export Message Logger",
"message_logger_export_text": "Choose what to export.",
"message_logger_export_individual_chat": "Export Individual Chat",
"message_logger_export_full_database": "Export Full Database",
"message_logger_select_chat_title": "Export Individual Chat",
"message_logger_select_chat_text": "Search by username, display name, or chat name.",
"message_logger_continue_button": "Continue",
"message_logger_select_export_format_title": "Select Export Format",
"message_logger_select_export_format_text": "Choose how to export the selected chat.",
"message_logger_export_format_db": ".db",
"message_logger_export_format_html": "HTML",
"message_logger_export_format_txt": "TXT",
"message_logger_no_chats_found": "No chats found",
"message_logger_no_messages_export_text": "No messages found in this chat.",
"message_logger_conversation_id": "Conversation ID: {id}",
"message_logger_message_count": "{count} messages",
"message_logger_missing_attachment_placeholder": "Attachment unavailable",
"message_logger_export_failed_toast": "Export failed: {message}",
"message_logger_missing_conversation_toast": "Missing conversation ID",
"message_logger_empty_chat_toast": "Selected chat has no messages to export",
"import_button": "Import",
"clear_button": "Clear",
"view_logger_history_button": "View Logger History",
@@ -357,21 +377,22 @@
"remove_all_tasks_confirm": "Remove all tasks?"
},
"features": {
"disabled": "Disabled",
"export_option": "Export",
"import_option": "Import",
"reset_option": "Reset",
"config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}",
"config_export_failure_toast": "Failed to export config {error}",
"saved_config_snackbar": "Config saved",
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
"disabled": "Disabled",
"export_option": "Export",
"import_option": "Import",
"reset_option": "Reset",
"config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}",
"config_export_failure_toast": "Failed to export config {error}",
"saved_config_snackbar": "Config saved",
"older_required": "This feature requires Snapchat v{version} or older to work correctly",
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
"search_button": "Search",
"search_results_count": "{count} messages",
"clear_history": "Clear search history",
"subtitle": "Explore and manage premium features"
"subtitle": "Explore and manage premium features",
"digits_only_toast": "Only numbers are allowed."
},
"bypass_status": {
"active": "PurrAura Active",
@@ -1156,6 +1177,10 @@
"name": "Show Streak Expiration Info",
"description": "Shows a Streak Expiration timer next to the Streaks counter"
},
"sort_social_tab_by_streak_length": {
"name": "Sort Social Tab by Streak Length",
"description": "Shows friends with streaks first, ordered from the longest streak to the shortest in the social tab and friend picker"
},
"hide_friend_feed_entry": {
"name": "Hide Friend Feed Entry",
"description": "Hides a specific friend from the Friend Feed\nUse the social tab to manage this feature"
@@ -1253,6 +1278,16 @@
"description": "The custom Snap Score you want to display (max 9,999,999)"
}
}
},
"spoof_followers_count": {
"name": "Spoof Followers Count",
"description": "Spoof your follower count on your profile (local only).",
"properties": {
"custom_followers_count": {
"name": "Custom Followers Count",
"description": "Number to show (digits only)."
}
}
}
}
},
@@ -1510,7 +1545,10 @@
"name": "Bypass Message Action Restrictions",
"description": "Allows you to react to a snap without having opened it or to save an unsaveable message"
},
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
"pre_fetch_snaps": {
"name": "Snap Pre-Fetch",
"description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage."
},
"remove_groups_locked_status": {
"name": "Remove Groups Locked Status",
"description": "Allows you to view group information after being kicked"
@@ -1731,9 +1769,12 @@
},
"thermal_protection": {
"name": "Thermal Protection",
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40°C to prevent overheating"
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40\u00b0C to prevent overheating"
},
"only_on_wifi": {
"name": "Auto Open only on Wi-Fi",
"description": "Only process queue when connected to a Wi-Fi network to save mobile data"
},
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
"content_type_snap": "Snap",
"only_when_idle": {
"name": "Auto Open Schedule",
@@ -1743,41 +1784,9 @@
"name": "Auto Open Scheduler",
"description": "Define the start and end times for scheduled throttled processing."
},
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
}
},
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
"instant_translation": {
"name": "Message Translator",
"description": "Configure the message translator"
},
"auto_delete_sent_messages": {
"name": "Auto Delete Sent Messages",
"description": "Automatically deletes sent messages after a specified time period",
"properties": {
"allow_running_in_background": {
"name": "Allow Running in Background",
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
},
"delete_after_value": {
"name": "Delete After (value)",
"description": "Time value before deleting the sent message"
},
"delete_after_unit": {
"name": "Time Unit",
"description": "Select the time unit for deletion delay"
},
"message_types": {
"name": "Message Types",
"description": "Select which message types should be auto-deleted"
},
"show_countdown": {
"name": "Show Countdown",
"description": "Show countdown before deleting the message"
},
"show_notification": {
"name": "Show Notification",
"description": "Show notification during countdown"
"safe_processing": {
"name": "Auto Open with stealth pace",
"description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed."
}
}
},
@@ -1835,6 +1844,36 @@
}
}
},
"auto_delete_sent_messages": {
"name": "Auto Delete Sent Messages",
"description": "Automatically deletes sent messages after a specified time period",
"properties": {
"allow_running_in_background": {
"name": "Allow Running in Background",
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
},
"delete_after_value": {
"name": "Delete After (value)",
"description": "Time value before deleting the sent message"
},
"delete_after_unit": {
"name": "Time Unit",
"description": "Select the time unit for deletion delay"
},
"message_types": {
"name": "Message Types",
"description": "Select which message types should be auto-deleted"
},
"show_countdown": {
"name": "Show Countdown",
"description": "Show countdown before deleting the message"
},
"show_notification": {
"name": "Show Notification",
"description": "Show notification during countdown"
}
}
},
"scheduled_send_allow_running_in_background": {
"name": "Allow Scheduled Send to Run in Background",
"description": "Keep scheduled messages processing while Snapchat is in the background"
@@ -2120,7 +2159,15 @@
"name": "HEVC Recording",
"description": "Uses HEVC (H.265) codec for video recording"
},
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": {
"camera_tweaks": {
"name": "Upgraded Camera Engine",
"description": "Enables professional hardware ISP processing modes for better dynamic range"
},
"audio_video": {
"name": "Upgraded Audio and Video",
"description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz"
},
"video_record_timer": {
"name": "Video Recording Timer",
"description": "Shows a recording timer overlay when recording video"
},
@@ -2690,7 +2737,11 @@
}
}
},
"network_optimization": { "name": "Improved Network Connectivity", "description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance" }, "better_transcript": {
"network_optimization": {
"name": "Improved Network Connectivity",
"description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance"
},
"better_transcript": {
"name": "Better Transcript",
"description": "Improves the voice note transcript",
"properties": {
@@ -2890,6 +2941,8 @@
"unsaveable_messages": "\u2b07\ufe0f Unsaveable Messages",
"auto_open_snaps": "\ud83d\udcf7 Auto Open Snaps",
"stealth": "\ud83d\udc7b Full Stealth Mode",
"snap_stealth": "\ud83d\udcf7 Snap Stealth Mode",
"chat_stealth": "\ud83d\udcac Chat 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",
@@ -3251,7 +3304,10 @@
"platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)",
"location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled",
"ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor",
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps"
"director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps",
"memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera",
"skip_own_indicators": "Hides indicator icons on your own sent snaps (Self-Snaps) \ud83d\udc64",
"disable_indicators_in_groups": "Disables all indicator icons in group conversations to reduce UI clutter \ud83d\udc65"
},
"auto_mark_as_read": {
"conversation_read": "Mark conversation as read when sending a message",
@@ -3832,7 +3888,12 @@
"export_failed_toast": "Failed to export account. Check logs for more info.",
"forced_logout_toast": "Removed account due to forced logout"
},
"auto_open_snaps": { "title": "Auto Open Snaps", "processed_count": "Opened", "queue_size": "Queue", "action_reset": "Reset Statistics", "priority_title": "Auto Open Snaps (Priority)",
"auto_open_snaps": {
"title": "Auto Open Snaps",
"processed_count": "Opened",
"queue_size": "Queue",
"action_reset": "Reset Count",
"priority_title": "Auto Open Snaps (Priority)",
"auto_open_schedule": {
"title": "Auto Open Scheduler",
"start": "Start",
@@ -3849,7 +3910,6 @@
"action_pause": "Pause",
"action_resume": "Resume",
"action_clear": "Clear Queue",
"action_reset": "Reset Count",
"error_content": "Failed to open snap from {sender}: {error}",
"resumed_feedback": "Auto Open Resumed",
"paused_feedback": "Auto Open Paused",
@@ -3866,9 +3926,9 @@
"speed_throttled": "Throttled",
"estimated_time": "Estimated Time",
"notification_statistics": "STATISTICS",
"notification_total_opened": "Lifetime Opened",
"notification_total_opened": "Total Snaps Opened",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_no_snaps_queue": "Monitoring snaps in background...",
"notification_no_snaps_queue": "No snaps in queue.",
"queue_cleared": "Queue cleared and statistics reset",
"queue_cleared_title": "Queue cleared",
"queue_cleared_reset": "Queue Cleared & Reset",
@@ -3883,12 +3943,8 @@
"conversation_type_group_chat": "Group Chat",
"conversation_type_chat": "Chat",
"notification_status": "Status",
"notification_statistics": "STATISTICS",
"notification_queue_size": "Queue Size",
"notification_total_opened": "Total Snaps Opened",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_processing_continue": "Processing will continue automatically...",
"notification_no_snaps_queue": "No snaps in queue.",
"notification_queue_cleared_opened": "Queue cleared ({opened} opened)",
"content_type_photo_video_snap": "Photo/Video Snap",
"conversation_type_group_with_name": "Group: {name}",
@@ -4016,7 +4072,7 @@
"username": "Username",
"user_id": "User ID",
"posted_on": "Posted",
"loading_username": "Loading…",
"loading_username": "Loading\u2026",
"username_copied": "Username copied",
"user_id_copied": "User ID copied",
"friend_status": "Friend status",
@@ -4101,10 +4157,10 @@
"search": {
"placeholder": "Search"
},
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"search_by": "Search by",
"since": "Since",
"until": "Until",
@@ -4311,8 +4367,6 @@
"added": "Added",
"no_friends_found": "No friends found",
"no_messages": "No messages",
"message": "Message",
"type_message": "Type message...",
"exporting_memories": "Exporting memories... ({failed} failed)"
},
"clear_friend_feed": "Clear Friend Feed",
@@ -4457,4 +4511,4 @@
"tasks_remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
}
}

View File

@@ -2544,7 +2544,10 @@
"platform_indicator": "वह प्लेटफ़ॉर्म आइकन जोड़ता है जहाँ से मीडिया भेजा गया था (उदा. Android, iOS, Web)",
"location_indicator": "Snaps में \ud83d\udccd आइकन जोड़ता है जब उन्हें लोकेशन सक्षम के साथ भेजा गया हो",
"ovf_editor_indicator": "इंगित करता है कि क्या कोई Snap OVF एडिटर का उपयोग करके भेजा गया है",
"director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है"
"director_mode_indicator": "Snaps में \u270f\ufe0f आइकन जोड़ता है जब उन्हें डायरेक्टर मोड का उपयोग करके भेजा गया हो, जिसका उपयोग गैलरी छवियों को Snaps के रूप में भेजने के लिए किया जा सकता है",
"memories_indicator": "मेमोरीज़ से फिर से भेजे गए Snaps में \ud83d\udcd6 आइकन जोड़ता है",
"skip_own_indicators": "अपने स्वयं के भेजे गए Snaps पर संकेतक आइकन छुपाता है \ud83d\udc64",
"disable_indicators_in_groups": "UI अव्यवस्था को कम करने के लिए समूह वार्तालापों में सभी संकेतकों को अक्षम करता है \ud83d\udc65"
},
"auto_mark_as_read": {
"conversation_read": "संदेश भेजते समय वार्तालाप को पढ़े गए के रूप में चिह्नित करें",

View File

@@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.common.bridge.wrapper
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import com.google.gson.GsonBuilder
@@ -20,6 +21,7 @@ import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import java.io.File
import java.io.InputStream
import java.util.UUID
class LoggedMessage(
@@ -70,10 +72,74 @@ data class TrackerLog(
}
}
data class LoggerConversationExportTarget(
val conversationId: String,
val groupTitle: String?,
val usernames: List<String>,
val userIds: List<String>,
val messageCount: Int
)
data class ConversationExportResult(
val messageCount: Int,
val chatEditCount: Int,
val trackerEventCount: Int
)
data class DatabaseImportResult(
val messageCount: Int,
val storyCount: Int
)
class LoggerWrapper(
val databaseFile: File,
private val readOnly: Boolean = false
): LoggerInterface.Stub() {
companion object {
private val MESSAGE_LOGGER_SCHEMA = mapOf(
"messages" to listOf(
"id INTEGER PRIMARY KEY",
"message_id BIGINT",
"conversation_id VARCHAR",
"user_id CHAR(36)",
"username VARCHAR",
"send_timestamp BIGINT",
"added_timestamp BIGINT",
"group_title VARCHAR",
"message_data BLOB"
),
"chat_edits" to listOf(
"id INTEGER PRIMARY KEY",
"edit_number INTEGER",
"added_timestamp BIGINT",
"conversation_id VARCHAR",
"message_id BIGINT",
"message_text BLOB"
),
"stories" to listOf(
"id INTEGER PRIMARY KEY",
"added_timestamp BIGINT",
"user_id VARCHAR",
"posted_timestamp BIGINT",
"created_timestamp BIGINT",
"url VARCHAR",
"encryption_key BLOB",
"encryption_iv BLOB"
),
"tracker_events" to listOf(
"id INTEGER PRIMARY KEY",
"timestamp BIGINT",
"conversation_id CHAR(36)",
"conversation_title VARCHAR",
"is_group BOOLEAN",
"username VARCHAR",
"user_id VARCHAR",
"event_type VARCHAR",
"data VARCHAR"
)
)
}
constructor(context: Context, uri: Uri? = null): this(
uri?.path?.let { File(it) } ?: File(context.getDatabasePath(InternalFileHandleType.MESSAGE_LOGGER.fileName).absolutePath),
uri != null
@@ -84,68 +150,190 @@ class LoggerWrapper(
private val coroutineScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1))
private val gson by lazy { GsonBuilder().create() }
private fun openDatabase(file: File, readOnly: Boolean): SQLiteDatabase {
val dbFlags = if (readOnly) {
SQLiteDatabase.OPEN_READONLY
} else {
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
}
return SQLiteDatabase.openDatabase(file.absolutePath, null, dbFlags).also { openedDatabase ->
if (!readOnly) {
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, MESSAGE_LOGGER_SCHEMA)
}
}
}
private fun closeDatabaseLocked() {
_database?.takeIf { it.isOpen }?.close()
_database = null
}
private val database get() = synchronized(this) {
_database?.takeIf { it.isOpen } ?: run {
_database?.close()
val dbFlags = if (readOnly) SQLiteDatabase.OPEN_READONLY else SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
val openedDatabase = SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, dbFlags)
if (!readOnly) {
SQLiteDatabaseHelper.createTablesFromSchema(openedDatabase, mapOf(
"messages" to listOf(
"id INTEGER PRIMARY KEY",
"message_id BIGINT",
"conversation_id VARCHAR",
"user_id CHAR(36)",
"username VARCHAR",
"send_timestamp BIGINT",
"added_timestamp BIGINT",
"group_title VARCHAR",
"message_data BLOB"
),
"chat_edits" to listOf(
"id INTEGER PRIMARY KEY",
"edit_number INTEGER",
"added_timestamp BIGINT",
"conversation_id VARCHAR",
"message_id BIGINT",
"message_text BLOB"
),
"stories" to listOf(
"id INTEGER PRIMARY KEY",
"added_timestamp BIGINT",
"user_id VARCHAR",
"posted_timestamp BIGINT",
"created_timestamp BIGINT",
"url VARCHAR",
"encryption_key BLOB",
"encryption_iv BLOB"
),
"tracker_events" to listOf(
"id INTEGER PRIMARY KEY",
"timestamp BIGINT",
"conversation_id CHAR(36)",
"conversation_title VARCHAR",
"is_group BOOLEAN",
"username VARCHAR",
"user_id VARCHAR",
"event_type VARCHAR",
"data VARCHAR"
)
))
}
closeDatabaseLocked()
val openedDatabase = openDatabase(databaseFile, readOnly)
_database = openedDatabase
openedDatabase
}
}
protected fun finalize() {
_database?.close()
synchronized(this) {
closeDatabaseLocked()
}
}
fun init() {
}
private fun resolveDatabaseSidecars(file: File): List<File> {
return listOf(
file,
File("${file.absolutePath}-wal"),
File("${file.absolutePath}-shm"),
File("${file.absolutePath}-journal")
)
}
private fun deleteIfExists(file: File) {
if (file.exists() && !file.delete()) {
throw IllegalStateException("Failed to delete ${file.name}")
}
}
private fun replaceFile(source: File, target: File) {
if (!source.exists()) {
throw IllegalStateException("Missing source file ${source.name}")
}
if (source.renameTo(target)) return
source.inputStream().use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
if (!source.delete()) {
throw IllegalStateException("Failed to delete temporary file ${source.name}")
}
}
private fun requireCompatibleSchema(db: SQLiteDatabase) {
MESSAGE_LOGGER_SCHEMA.forEach { (tableName, columns) ->
val existingColumns = mutableListOf<String>()
db.rawQuery("PRAGMA table_info($tableName)", null).use { cursor ->
while (cursor.moveToNext()) {
val columnName = cursor.getStringOrNull("name") ?: continue
val columnType = cursor.getStringOrNull("type") ?: ""
existingColumns.add("$columnName $columnType".trim())
}
}
if (existingColumns.isEmpty()) {
throw IllegalStateException("Selected database is missing required table $tableName")
}
val missingColumns = columns.filter { expectedColumn ->
!expectedColumn.uppercase().startsWith("PRIMARY KEY") &&
existingColumns.none { existingColumn -> expectedColumn.startsWith(existingColumn) }
}
if (missingColumns.isNotEmpty()) {
throw IllegalStateException(
"Selected database has incompatible schema for $tableName: ${missingColumns.joinToString()}"
)
}
}
}
private fun requireIntegrity(db: SQLiteDatabase) {
val integrityResult = db.rawQuery("PRAGMA integrity_check(1)", null).use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0)
}
if (integrityResult == null || !integrityResult.equals("ok", ignoreCase = true)) {
throw IllegalStateException("Selected database failed integrity check: ${integrityResult ?: "unknown"}")
}
}
private fun getTableCount(db: SQLiteDatabase, tableName: String): Int {
return db.rawQuery("SELECT COUNT(*) FROM $tableName", null).use { cursor ->
if (!cursor.moveToFirst()) 0 else cursor.getInt(0)
}
}
fun importDatabase(inputStream: InputStream): DatabaseImportResult {
if (readOnly) {
throw IllegalStateException("Cannot import into read-only logger")
}
val databaseDir = databaseFile.parentFile
?: throw IllegalStateException("Cannot resolve message logger database directory")
if (!databaseDir.exists() && !databaseDir.mkdirs()) {
throw IllegalStateException("Failed to create message logger directory")
}
val tempFile = File(
databaseDir,
"${databaseFile.name}.import-${System.currentTimeMillis()}-${UUID.randomUUID()}"
)
val backupFile = File(
databaseDir,
"${databaseFile.name}.backup-${System.currentTimeMillis()}-${UUID.randomUUID()}"
)
try {
inputStream.use { input ->
tempFile.outputStream().use { output ->
val copiedBytes = input.copyTo(output)
if (copiedBytes <= 0L) {
throw IllegalStateException("Selected backup is empty")
}
}
}
openDatabase(tempFile, readOnly = true).use { importedDatabase ->
requireIntegrity(importedDatabase)
requireCompatibleSchema(importedDatabase)
}
synchronized(this) {
closeDatabaseLocked()
val hadExistingDatabase = databaseFile.exists()
if (hadExistingDatabase) {
databaseFile.inputStream().use { input ->
backupFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
try {
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
replaceFile(tempFile, databaseFile)
resolveDatabaseSidecars(databaseFile).filter { it != databaseFile }.forEach(::deleteIfExists)
val reopenedDatabase = openDatabase(databaseFile, readOnly = false)
val importResult = DatabaseImportResult(
messageCount = getTableCount(reopenedDatabase, "messages"),
storyCount = getTableCount(reopenedDatabase, "stories")
)
_database = reopenedDatabase
deleteIfExists(backupFile)
return importResult
} catch (throwable: Throwable) {
runCatching {
resolveDatabaseSidecars(databaseFile).forEach(::deleteIfExists)
if (backupFile.exists()) {
replaceFile(backupFile, databaseFile)
}
}
closeDatabaseLocked()
throw throwable
}
}
} finally {
runCatching { deleteIfExists(tempFile) }
runCatching { deleteIfExists(backupFile) }
}
}
override fun getLoggedIds(conversationId: Array<String>, limit: Int): LongArray {
if (conversationId.any {
runCatching { UUID.fromString(it) }.isFailure
@@ -425,6 +613,223 @@ class LoggerWrapper(
return ConversationInfo(conversationId, usernames.size, groupTitle, usernames)
}
fun getConversationExportTargets(): List<LoggerConversationExportTarget> {
val groupedConversations = mutableListOf<Triple<String, String?, Int>>()
database.rawQuery(
"SELECT conversation_id, MAX(group_title) AS group_title, COUNT(*) AS message_count, MAX(send_timestamp) AS last_timestamp " +
"FROM messages WHERE conversation_id IS NOT NULL AND TRIM(conversation_id) != '' " +
"GROUP BY conversation_id ORDER BY last_timestamp DESC",
null
).use { cursor ->
while (cursor.moveToNext()) {
val conversationId = cursor.getStringOrNull("conversation_id")?.takeIf { it.isNotBlank() } ?: continue
groupedConversations.add(
Triple(
conversationId,
cursor.getStringOrNull("group_title"),
cursor.getIntOrNull("message_count") ?: 0
)
)
}
}
return groupedConversations.map { (conversationId, groupTitle, messageCount) ->
val userIds = linkedSetOf<String>()
val usernames = linkedSetOf<String>()
database.rawQuery(
"SELECT DISTINCT user_id, username FROM messages WHERE conversation_id = ?",
arrayOf(conversationId)
).use { cursor ->
while (cursor.moveToNext()) {
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { userIds.add(it) }
cursor.getStringOrNull("username")?.takeIf { it.isNotBlank() }?.let { usernames.add(it) }
}
}
LoggerConversationExportTarget(
conversationId = conversationId,
groupTitle = groupTitle,
usernames = usernames.toList(),
userIds = userIds.toList(),
messageCount = messageCount
)
}
}
fun exportConversationDatabase(
outputFile: File,
conversationId: String,
userIds: Collection<String> = emptyList()
): ConversationExportResult {
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
?: throw IllegalArgumentException("Conversation ID cannot be empty")
val normalizedUserIds = userIds
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.toSet()
.toMutableSet()
.also { ids ->
if (ids.isEmpty()) {
database.rawQuery(
"SELECT DISTINCT user_id FROM messages WHERE conversation_id = ? AND user_id IS NOT NULL AND TRIM(user_id) != ''",
arrayOf(normalizedConversationId)
).use { cursor ->
while (cursor.moveToNext()) {
cursor.getStringOrNull("user_id")?.takeIf { it.isNotBlank() }?.let { ids.add(it) }
}
}
}
}
outputFile.parentFile?.mkdirs()
if (outputFile.exists() && !outputFile.delete()) {
throw IllegalStateException("Failed to prepare export file")
}
val outputDatabase = SQLiteDatabase.openDatabase(
outputFile.absolutePath,
null,
SQLiteDatabase.CREATE_IF_NECESSARY or SQLiteDatabase.OPEN_READWRITE
)
var transactionStarted = false
try {
SQLiteDatabaseHelper.createTablesFromSchema(outputDatabase, MESSAGE_LOGGER_SCHEMA)
outputDatabase.beginTransaction()
transactionStarted = true
val messageWhereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val messageWhereArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
val messageCount = copyQueryRows(
sourceQuery = "SELECT * FROM messages WHERE $messageWhereClause ORDER BY send_timestamp ASC",
sourceArgs = messageWhereArgs,
targetDatabase = outputDatabase,
targetTable = "messages"
)
val chatEditCount = copyQueryRows(
sourceQuery = "SELECT * FROM chat_edits WHERE conversation_id = ? AND message_id IN (SELECT message_id FROM messages WHERE $messageWhereClause) ORDER BY added_timestamp ASC",
sourceArgs = arrayOf(normalizedConversationId, *messageWhereArgs),
targetDatabase = outputDatabase,
targetTable = "chat_edits"
)
val trackerWhereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val trackerArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
val trackerEventCount = copyQueryRows(
sourceQuery = "SELECT * FROM tracker_events WHERE $trackerWhereClause ORDER BY timestamp ASC",
sourceArgs = trackerArgs,
targetDatabase = outputDatabase,
targetTable = "tracker_events"
)
outputDatabase.setTransactionSuccessful()
return ConversationExportResult(
messageCount = messageCount,
chatEditCount = chatEditCount,
trackerEventCount = trackerEventCount
)
} finally {
if (transactionStarted) {
outputDatabase.endTransaction()
}
outputDatabase.close()
}
}
private fun cursorToContentValues(cursor: Cursor): ContentValues {
return ContentValues(cursor.columnCount).apply {
for (columnIndex in 0 until cursor.columnCount) {
val columnName = cursor.getColumnName(columnIndex)
when (cursor.getType(columnIndex)) {
Cursor.FIELD_TYPE_NULL -> putNull(columnName)
Cursor.FIELD_TYPE_INTEGER -> put(columnName, cursor.getLong(columnIndex))
Cursor.FIELD_TYPE_FLOAT -> put(columnName, cursor.getDouble(columnIndex))
Cursor.FIELD_TYPE_STRING -> put(columnName, cursor.getString(columnIndex))
Cursor.FIELD_TYPE_BLOB -> put(columnName, cursor.getBlob(columnIndex))
}
}
}
}
private fun copyQueryRows(
sourceQuery: String,
sourceArgs: Array<String>? = null,
targetDatabase: SQLiteDatabase,
targetTable: String
): Int {
var rowCount = 0
database.rawQuery(sourceQuery, sourceArgs).use { cursor ->
while (cursor.moveToNext()) {
targetDatabase.insert(targetTable, null, cursorToContentValues(cursor))
rowCount++
}
}
return rowCount
}
private fun cursorToLoggedMessage(cursor: Cursor): LoggedMessage? {
return LoggedMessage(
messageId = cursor.getLongOrNull("message_id") ?: return null,
conversationId = cursor.getStringOrNull("conversation_id") ?: return null,
userId = cursor.getStringOrNull("user_id") ?: return null,
username = cursor.getStringOrNull("username") ?: return null,
sendTimestamp = cursor.getLongOrNull("send_timestamp") ?: return null,
addedTimestamp = cursor.getLongOrNull("added_timestamp") ?: return null,
groupTitle = cursor.getStringOrNull("group_title"),
messageData = cursor.getBlobOrNull("message_data") ?: return null
)
}
fun forEachConversationMessage(
conversationId: String,
userIds: Collection<String> = emptyList(),
orderAscending: Boolean = true,
block: (LoggedMessage) -> Unit
): Int {
val normalizedConversationId = conversationId.trim().takeIf { it.isNotEmpty() }
?: throw IllegalArgumentException("Conversation ID cannot be empty")
val normalizedUserIds = userIds
.mapNotNull { it.trim().takeIf(String::isNotEmpty) }
.toSet()
val whereClause = buildString {
append("conversation_id = ?")
if (normalizedUserIds.isNotEmpty()) {
append(" AND user_id IN (${normalizedUserIds.joinToString(",") { "?" }})")
}
}
val whereArgs = mutableListOf(normalizedConversationId).apply {
addAll(normalizedUserIds)
}.toTypedArray()
var total = 0
database.rawQuery(
"SELECT * FROM messages WHERE $whereClause ORDER BY send_timestamp ${if (orderAscending) "ASC" else "DESC"}",
whereArgs
).use { cursor ->
while (cursor.moveToNext()) {
cursorToLoggedMessage(cursor)?.let { loggedMessage ->
block(loggedMessage)
total++
}
}
}
return total
}
override fun getChatEdits(conversationId: String, messageId: Long): List<LoggedChatEdit> {
val edits = mutableListOf<LoggedChatEdit>()
database.rawQuery(
@@ -483,16 +888,7 @@ class LoggerWrapper(
arrayOf(conversationId, fromTimestamp.toString())
).use {
while (it.moveToNext() && messages.size < limit) {
val message = LoggedMessage(
messageId = it.getLongOrNull("message_id") ?: continue,
conversationId = it.getStringOrNull("conversation_id") ?: continue,
userId = it.getStringOrNull("user_id") ?: continue,
username = it.getStringOrNull("username") ?: continue,
sendTimestamp = it.getLongOrNull("send_timestamp") ?: continue,
addedTimestamp = it.getLongOrNull("added_timestamp") ?: continue,
groupTitle = it.getStringOrNull("group_title"),
messageData = it.getBlobOrNull("message_data") ?: continue
)
val message = cursorToLoggedMessage(it) ?: continue
if (filter != null && !filter(message)) continue
messages.add(message)
}

View File

@@ -1,7 +1,6 @@
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
@@ -80,9 +79,7 @@ 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)
} ?: JsonNull.INSTANCE
val serializedValue = propertyValue.getRaw()?.let { propertyKey.dataType.serializeAny(it, exportSensitiveData, includeSavedLocations) }
json.add(propertyKey.name, serializedValue)
}
return json

View File

@@ -81,6 +81,7 @@ class ConfigParams(
var inputCheck: ((String) -> Boolean)? = { true },
var filenameFilter: ((String) -> Boolean)? = null,
var versionCheck: VersionCheck? = null,
var digitsOnlyInput: Boolean = false,
) {
val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()
val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()

View File

@@ -55,9 +55,7 @@ class Global : ConfigContainer() {
}
}
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }.apply {
profile.set("max")
}
val performanceMode = container("performance_mode", PerformanceModeConfig()) { requireRestart() }
val disableConfirmationDialogs = multiple("disable_confirmation_dialogs", "erase_message", "remove_friend", "block_friend", "ignore_friend", "hide_friend", "hide_conversation", "clear_conversation") { requireRestart() }
val disableMetrics = boolean("disable_metrics") { requireRestart() }
val disableStorySections = multiple("disable_story_sections", "friends", "suggested_stories", "following", "discover") { requireRestart(); requireCleanCache() }
@@ -80,8 +78,6 @@ class Global : ConfigContainer() {
inner class UpdateSettings : ConfigContainer() {
val autoUpdateCheck = boolean("auto_update_check", true)
val updateCheckFrequency = unique("update_check_frequency", "daily", "weekly", "monthly")
val updateChannel = unique("update_channel", "stable", "prerelease")
}
inner class UISettings : ConfigContainer() {

View File

@@ -186,6 +186,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
val currentProfileSnapshot = string("current_profile_snapshot") {
addFlags(ConfigFlag.HIDDEN)
}
val profileData = string("profile_data") {
addFlags(ConfigFlag.HIDDEN)
}
}
inner class SpoofDeviceIdConfig : ConfigContainer() {

View File

@@ -33,6 +33,7 @@ class UserInterfaceTweaks : ConfigContainer() {
val mapFriendNameTags = boolean("map_friend_nametags") { requireRestart() }
val preventMessageListAutoScroll = boolean("prevent_message_list_auto_scroll") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val streakExpirationInfo = boolean("streak_expiration_info") { requireRestart() }
val sortSocialTabByStreakLength = boolean("sort_social_tab_by_streak_length").apply { set(true) }
val hideFriendFeedEntry = boolean("hide_friend_feed_entry") { requireRestart() }
val hideStreakRestore = boolean("hide_streak_restore") { requireRestart() }
val hideQuickAddSuggestions = boolean("hide_quick_add_suggestions") { requireRestart() }
@@ -56,7 +57,7 @@ class UserInterfaceTweaks : ConfigContainer() {
val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() }
val disableSpotlight = boolean("disable_spotlight") { requireRestart() }
val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() }
val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator") { requireRestart() }
val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator", "skip_own_indicators", "disable_indicators_in_groups") { requireRestart() }
val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() }
val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") {
requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR)
@@ -76,4 +77,14 @@ class UserInterfaceTweaks : ConfigContainer() {
}
val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() }
inner class SpoofFollowersCount : ConfigContainer(hasGlobalState = true) {
val customFollowersCount = string("custom_followers_count") {
requireRestart()
digitsOnlyInput = true
inputCheck = { input -> input.isEmpty() || input.all { it.isDigit() } }
}
}
val spoofFollowersCount = container("spoof_followers_count", SpoofFollowersCount()) { requireRestart() }
}

View File

@@ -49,8 +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),
SNAP_STEALTH("snap_stealth", true, Icons.Outlined.PhotoCamera, showInFriendMenu = true),
CHAT_STEALTH("chat_stealth", true, Icons.Outlined.ChatBubbleOutline, showInFriendMenu = true),
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"),
@@ -65,6 +65,7 @@ enum class MessagingRuleType(
AUTO_DELETE_SENT_MESSAGES("auto_delete_sent_messages", true, Icons.Outlined.DeleteSweep, defaultValue = "blacklist");
fun translateOptionKey(optionKey: String): String {
if (key.contains("stealth")) return "features.options.friend_feed_menu_buttons.$key"
return if (listMode) "rules.properties.$key.options.$optionKey" else "rules.properties.$key.name"
}

View File

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.*
import android.util.Log
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Semaphore
@@ -41,16 +42,111 @@ class BridgeClient(
private var continuation: Continuation<Boolean>? = null
private val connectSemaphore = Semaphore(permits = 1)
private val reconnectSemaphore = Semaphore(permits = 1)
private lateinit var service: BridgeInterface
private val serviceStateLock = Any()
@Volatile
private var service: BridgeInterface? = null
@Volatile
private var serviceBinder: IBinder? = null
@Volatile
private var isBound = false
@Volatile
private var isHandlingServiceConnection = false
private val connectionExecutor = Executors.newSingleThreadExecutor()
private val legacyBindThread = HandlerThread("BridgeClient").apply { start() }
private val legacyBindHandler by lazy { Handler(legacyBindThread.looper) }
private val onConnectedCallbacks = mutableListOf<suspend () -> Unit>()
private var cachePurrfectSnapApkPath: String? = null
private val serviceDeathRecipient = IBinder.DeathRecipient {
clearConnectedService()
}
private fun clearConnectedServiceLocked() {
serviceBinder?.let { binder ->
runCatching { binder.unlinkToDeath(serviceDeathRecipient, 0) }
}
serviceBinder = null
service = null
}
private fun clearConnectedService() {
synchronized(serviceStateLock) {
clearConnectedServiceLocked()
}
}
private fun attachConnectedService(binder: IBinder): Boolean {
synchronized(serviceStateLock) {
clearConnectedServiceLocked()
serviceBinder = binder
service = BridgeInterface.Stub.asInterface(binder)
return runCatching {
binder.linkToDeath(serviceDeathRecipient, 0)
true
}.getOrElse { throwable ->
Log.w("BridgeClient", "Failed to link bridge death recipient", throwable)
clearConnectedServiceLocked()
false
}
}
}
private fun isServiceAlive(): Boolean {
val binder = serviceBinder ?: service?.asBinder() ?: return false
return binder.isBinderAlive && binder.pingBinder()
}
private val connectedService: BridgeInterface
get() {
val currentService = service ?: throw DeadObjectException()
val binder = currentService.asBinder()
if (!binder.isBinderAlive || !binder.pingBinder()) throw DeadObjectException()
return currentService
}
private fun Context.unbindBridgeIfNeeded() {
if (!isBound) return
runCatching { unbindService(this@BridgeClient) }.onFailure { throwable ->
if (throwable !is IllegalArgumentException) throw throwable
}
isBound = false
}
private fun Context.bindBridge(intent: Intent): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bindService(
intent,
Context.BIND_AUTO_CREATE,
connectionExecutor,
this@BridgeClient
)
} else {
this::class.java.methods.firstOrNull {
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
}?.invoke(
this,
intent,
this@BridgeClient,
Context.BIND_AUTO_CREATE,
legacyBindHandler,
Process.myUserHandle()
) as? Boolean ?: false
}
}
private fun isRecoverableBinderFailure(throwable: Throwable): Boolean {
return throwable is DeadObjectException ||
throwable is RemoteException ||
throwable.cause is DeadObjectException ||
throwable.cause is RemoteException
}
fun addOnConnectedCallback(initNow: Boolean = false, callback: suspend () -> Unit) {
synchronized(onConnectedCallbacks) {
onConnectedCallbacks.add(callback)
}
initNow.takeIf { it && this::service.isInitialized }?.let {
initNow.takeIf { it && isServiceAlive() }?.let {
runBlocking {
callback()
}
@@ -67,7 +163,7 @@ class BridgeClient(
}
suspend fun connect(onFailure: (Throwable) -> Unit): Boolean? {
if (this::service.isInitialized && service.asBinder().pingBinder()) {
if (isServiceAlive()) {
return true
}
@@ -75,10 +171,9 @@ class BridgeClient(
val retryDelay = 3000L
return withTimeoutOrNull(connectionTimeout) {
var result: Boolean? = null
for (retry in 0.. (connectionTimeout / retryDelay).toInt()) {
result = withTimeoutOrNull(retryDelay) {
val attempts = (connectionTimeout / retryDelay).toInt() + 1
repeat(attempts) { attempt ->
val result = withTimeoutOrNull(retryDelay) {
suspendCancellableCoroutine { cancellableContinuation ->
continuation = cancellableContinuation
with(context.androidContext) {
@@ -93,31 +188,12 @@ class BridgeClient(
runCatching {
val intent = Intent()
.setClassName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BridgeService")
runCatching {
if (this@BridgeClient::service.isInitialized) {
unbindService(this@BridgeClient)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
bindService(
intent,
Context.BIND_AUTO_CREATE,
Executors.newSingleThreadExecutor(),
this@BridgeClient
)
} else {
val handler = Handler(HandlerThread("BridgeClient").apply { start() }.looper)
this::class.java.methods.firstOrNull {
it.name == "bindServiceAsUser" && it.parameterTypes.size == 5
}?.invoke(
this,
intent,
this@BridgeClient,
Context.BIND_AUTO_CREATE,
handler,
Process.myUserHandle()
) ?: throw NoSuchMethodException("bindServiceAsUser")
unbindBridgeIfNeeded()
clearConnectedService()
if (!bindBridge(intent)) {
throw IllegalStateException("bindService returned false")
}
isBound = true
}.onFailure {
onFailure(it)
resumeContinuation(false)
@@ -125,46 +201,82 @@ class BridgeClient(
}
}
}
if (result != null) break
if (result == true) {
return@withTimeoutOrNull true
}
if (attempt + 1 < attempts) {
delay(250L)
}
}
result
false
}
}
override fun onServiceConnected(name: ComponentName, service: IBinder) {
this.service = BridgeInterface.Stub.asInterface(service)
runBlocking {
onConnectedCallbacks.forEach {
runCatching {
it()
}.onFailure {
context.log.error("Failed to run onConnectedCallback", it)
}
}
}
cachePurrfectSnapApkPath = this.service.applicationApkPath.also {
if (cachePurrfectSnapApkPath != null && cachePurrfectSnapApkPath != it) {
context.log.verbose("Restarting Snapchat due to PurrfectSnap update")
context.softRestartApp()
isHandlingServiceConnection = true
try {
if (!attachConnectedService(service)) {
resumeContinuation(false)
return
}
runBlocking {
onConnectedCallbacks.forEach {
runCatching {
it()
}.onFailure {
context.log.error("Failed to run onConnectedCallback", it)
}
}
}
val remoteApkPath = runCatching {
connectedService.applicationApkPath
}.getOrElse { throwable ->
if (isRecoverableBinderFailure(throwable)) {
Log.w("BridgeClient", "Bridge died during onServiceConnected initialization", throwable)
} else {
Log.e("BridgeClient", "Bridge initialization failed", throwable)
}
clearConnectedService()
resumeContinuation(false)
return
}
cachePurrfectSnapApkPath = remoteApkPath.also {
if (cachePurrfectSnapApkPath != null && cachePurrfectSnapApkPath != it) {
context.log.verbose("Restarting Snapchat due to PurrfectSnap update")
context.softRestartApp()
return
}
}
resumeContinuation(true)
} finally {
isHandlingServiceConnection = false
}
resumeContinuation(true)
}
override fun onNullBinding(name: ComponentName) {
clearConnectedService()
isBound = false
resumeContinuation(false)
}
override fun onServiceDisconnected(name: ComponentName) {
clearConnectedService()
isBound = false
continuation = null
}
override fun onBindingDied(name: ComponentName) {
clearConnectedService()
isBound = false
resumeContinuation(false)
}
private fun tryReconnect() {
runBlocking {
reconnectSemaphore.withPermit {
if (service.asBinder().pingBinder()) return@runBlocking
if (isServiceAlive()) return@withPermit
Log.d("BridgeClient", "service is dead, restarting")
val canLoad = connect {
Log.e("BridgeClient", "connection failed", it)
@@ -181,13 +293,16 @@ class BridgeClient(
return runCatching {
block()
}.getOrElse { throwable ->
if (throwable is DeadObjectException) {
tryReconnect()
return@getOrElse runCatching {
block()
}.getOrElse {
Log.e("BridgeClient", "service call failed", it)
throw it
if (isRecoverableBinderFailure(throwable)) {
clearConnectedService()
if (!isHandlingServiceConnection) {
tryReconnect()
return@getOrElse runCatching {
block()
}.getOrElse {
Log.e("BridgeClient", "service call failed", it)
throw it
}
}
}
throw throwable
@@ -197,15 +312,15 @@ class BridgeClient(
fun broadcastLog(tag: String, level: String, message: String) {
message.chunked(1024 * 256).forEach {
runCatching {
service.broadcastLog(tag, level, it)
connectedService.broadcastLog(tag, level, it)
}
}
}
fun getApplicationApkPath(): String = safeServiceCall { service.applicationApkPath }
fun getApplicationApkPath(): String = safeServiceCall { connectedService.applicationApkPath }
fun enqueueDownload(intent: Intent, callback: DownloadCallback) = safeServiceCall {
service.enqueueDownload(intent, callback)
connectedService.enqueueDownload(intent, callback)
}
fun convertMedia(
@@ -215,40 +330,42 @@ class BridgeClient(
audioCodec: String?,
videoCodec: String?
): ParcelFileDescriptor? = safeServiceCall {
service.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
connectedService.convertMedia(input, inputExtension, outputExtension, audioCodec, videoCodec)
}
fun sync(callback: SyncCallback) {
if (!context.database.hasMain()) return
safeServiceCall {
service.sync(callback)
connectedService.sync(callback)
}
}
fun triggerSync(scope: SocialScope, id: String) = safeServiceCall {
service.triggerSync(scope.key, id)
connectedService.triggerSync(scope.key, id)
}
fun passGroupsAndFriends(groups: List<MessagingGroupInfo>, friends: List<MessagingFriendInfo>) =
safeServiceCall {
val serializedGroups = groups.mapNotNull { it.toSerialized() }
val serializedFriends = friends.mapNotNull { it.toSerialized() }
// Binder transaction limit is 1MB. Use 128KB chunks to avoid TransactionTooLargeException.
val maxChunkBytes = 128 * 1024
fun chunkSerialized(values: List<String>): List<List<String>> {
fun calculateParts(values: List<String>): List<List<String>> {
if (values.isEmpty()) return listOf(emptyList())
val result = mutableListOf<List<String>>()
val currentChunk = mutableListOf<String>()
var currentChunk = mutableListOf<String>()
var currentSize = 0
values.forEach { value ->
val valueSize = value.toByteArray(StandardCharsets.UTF_8).size + 32
val valueSize = value.toByteArray(Charsets.UTF_8).size + 32
if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) {
result += currentChunk.toList()
currentChunk.clear()
currentChunk = mutableListOf()
currentSize = 0
}
currentChunk += value
currentChunk.add(value)
currentSize += valueSize
}
@@ -258,73 +375,71 @@ class BridgeClient(
return result
}
val groupChunks = chunkSerialized(serializedGroups)
val friendChunks = chunkSerialized(serializedFriends)
val chunkCount = maxOf(groupChunks.size, friendChunks.size)
val groupParts = calculateParts(serializedGroups)
val friendParts = calculateParts(serializedFriends)
val totalParts = maxOf(groupParts.size, friendParts.size)
context.log.info(
"Sending social snapshot in $chunkCount chunk(s): " +
"${serializedGroups.size} groups, ${serializedFriends.size} friends"
)
context.log.info("Synchronizing social data in $totalParts part(s): ${serializedGroups.size} groups, ${serializedFriends.size} friends")
repeat(chunkCount) { index ->
service.passGroupsAndFriends(
groupChunks.getOrElse(index) { emptyList() },
friendChunks.getOrElse(index) { emptyList() }
repeat(totalParts) { index ->
connectedService.passGroupsAndFriends(
groupParts.getOrElse(index) { emptyList() },
friendParts.getOrElse(index) { emptyList() },
index,
totalParts
)
}
}
fun getRules(targetUuid: String): List<MessagingRuleType> = safeServiceCall {
service.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) }
connectedService.getRules(targetUuid).mapNotNull { MessagingRuleType.getByName(it) }
}
fun getRuleIds(ruleType: MessagingRuleType): List<String> = safeServiceCall {
service.getRuleIds(ruleType.key)
connectedService.getRuleIds(ruleType.key)
}
fun setRule(targetUuid: String, type: MessagingRuleType, state: Boolean) = safeServiceCall {
service.setRule(targetUuid, type.key, state)
connectedService.setRule(targetUuid, type.key, state)
}
fun getScopeNotes(id: String): String? = safeServiceCall { service.getScopeNotes(id) }
fun getScopeNotes(id: String): String? = safeServiceCall { connectedService.getScopeNotes(id) }
fun setScopeNotes(id: String, content: String?) = safeServiceCall { service.setScopeNotes(id, content) }
fun setScopeNotes(id: String, content: String?) = safeServiceCall { connectedService.setScopeNotes(id, content) }
fun getAllScopeNotes(): Map<String, String> = safeServiceCall { service.getAllScopeNotes() }
fun getAllScopeNotes(): Map<String, String> = safeServiceCall { connectedService.getAllScopeNotes() }
fun setAllScopeNotes(notes: Map<String, String>) = safeServiceCall { service.setAllScopeNotes(notes) }
fun setAllScopeNotes(notes: Map<String, String>) = safeServiceCall { connectedService.setAllScopeNotes(notes) }
fun getScriptingInterface(): IScripting? = safeServiceCall<IScripting?> { service.scriptingInterface }
fun getScriptingInterface(): IScripting? = safeServiceCall<IScripting?> { connectedService.scriptingInterface }
fun getE2eeInterface(): E2eeInterface = safeServiceCall { service.e2eeInterface }
fun getE2eeInterface(): E2eeInterface = safeServiceCall { connectedService.e2eeInterface }
fun getMessageLogger(): LoggerInterface = safeServiceCall { service.logger }
fun getMessageLogger(): LoggerInterface = safeServiceCall { connectedService.logger }
fun getTracker(): TrackerInterface = safeServiceCall { service.tracker }
fun getTracker(): TrackerInterface = safeServiceCall { connectedService.tracker }
fun getAccountStorage(): AccountStorage = safeServiceCall { service.accountStorage }
fun getAccountStorage(): AccountStorage = safeServiceCall { connectedService.accountStorage }
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { service.fileHandleManager }
fun getFileHandlerManager(): FileHandleManager = safeServiceCall { connectedService.fileHandleManager }
fun getLocationManager(): LocationManager = safeServiceCall { service.locationManager }
fun getLocationManager(): LocationManager = safeServiceCall { connectedService.locationManager }
fun getTaskInterface(): TaskInterface = safeServiceCall { service.taskInterface }
fun getTaskInterface(): TaskInterface = safeServiceCall { connectedService.taskInterface }
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { service.registerMessagingBridge(bridge) }
fun registerMessagingBridge(bridge: MessagingBridge) = safeServiceCall { connectedService.registerMessagingBridge(bridge) }
fun openOverlay(type: OverlayType) = safeServiceCall { service.openOverlay(type.key) }
fun closeOverlay() = safeServiceCall { service.closeOverlay() }
fun openOverlay(type: OverlayType) = safeServiceCall { connectedService.openOverlay(type.key) }
fun closeOverlay() = safeServiceCall { connectedService.closeOverlay() }
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { service.registerConfigStateListener(listener) }
fun registerConfigStateListener(listener: ConfigStateListener) = safeServiceCall { connectedService.registerConfigStateListener(listener) }
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) }
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { connectedService.getDebugProp(name, defaultValue) }
fun startCallDownload(
startTimestamp: Long,
author: String,
): CallDownloadSession {
return safeServiceCall { service.startCallDownload(startTimestamp, author) }
return safeServiceCall { connectedService.startCallDownload(startTimestamp, author) }
}
}

View File

@@ -47,7 +47,7 @@ class EventDispatcher(
cacheHook(
methodParam.thisObject<Any>()::class.java
) {
hook(bindMethod.get().toString(), HookStage.BEFORE) bindViewMethod@{ param ->
hook(bindMethod.get().toString(), HookStage.AFTER) bindViewMethod@{ param ->
val instance = param.thisObject<Any>()
val view = instance::class.java.methods.firstOrNull {
it.name == getViewMethod.get().toString()
@@ -161,7 +161,6 @@ class EventDispatcher(
adapter = param
}
) {
if (canceled) param.setResult(null)
postHookEvent()
}
}

View File

@@ -32,7 +32,7 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
} && getRuleState() != null
}
fun canUseRule(conversationId: String): Boolean {
open fun canUseRule(conversationId: String): Boolean {
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {
return false
}

View File

@@ -69,6 +69,16 @@ class ConfigurationOverride : Feature("Configuration Override") {
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null },
{ true }, isAppExperiment = true)
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },
{ true })
overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false })
overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false })
arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach {
overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" })
}
run {
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
val level7Value = { _: ConfigKeyInfo -> 700 }
@@ -172,15 +182,6 @@ class ConfigurationOverride : Feature("Configuration Override") {
},
{ false })
overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() },
{ true })
overrideProperty("SPONSORED_SNAPS_ENABLED", { context.config.global.blockAds.get() }, { false })
overrideProperty("SPONSORED_SNAP_UPDATE_SPONSORED_FEED_ITEM", { context.config.global.blockAds.get() }, { false })
arrayOf("CUSTOM_AD_TRACKER_URL", "CUSTOM_AD_INIT_SERVER_URL", "CUSTOM_AD_SERVER_URL", "INIT_PRIMARY_URL", "INIT_SHADOW_URL", "GRAPHENE_HOST").forEach {
overrideProperty(it, { context.config.global.blockAds.get() }, { "http://127.0.0.1" })
}
overrideProperty("GIFTING_CHAT_BIRTHDAY_UPSELL_ENABLED", { context.config.userInterface.hideUiComponents.get().contains("hide_snapchat_plus_gift_reminders") }, { false })
classReference.getAsClass()?.hook(

View File

@@ -1,6 +1,5 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import android.app.ActivityManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -17,11 +16,11 @@ import androidx.core.content.edit
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import me.eternal.purrfectsnap.bridge.AutoOpenInterface
import me.eternal.purrfectsnap.common.config.PropertyValue
import me.eternal.purrfectsnap.common.config.ModConfig
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessageState
import me.eternal.purrfectsnap.common.data.MessageUpdate
@@ -33,6 +32,7 @@ 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,7 +42,7 @@ import kotlin.random.Random
/**
* AutoOpenSnaps: High-performance engine with real-time diagnostics.
* Optimized for 20+ snaps/s with accurate stats and background resilience.
* Optimized for background resilience and industrial stability.
*/
class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) {
companion object {
@@ -54,12 +54,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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 LAZY_SAVE_INTERVAL_MS = 600_000L
private const val PREF_SAVED_QUEUE = "auto_open_saved_queue"
}
private val gson = Gson()
private val isPaused = AtomicBoolean(false)
private val isScreenOn = AtomicBoolean(true)
private val engineActive = AtomicBoolean(true)
private val totalProcessed = AtomicInteger(0)
private val sessionProcessed = AtomicInteger(0)
@@ -67,9 +67,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
private val averageProcessingTime = AtomicLong(800)
private val lastSnapProcessedAt = AtomicLong(0)
private val snapChannel = Channel<SnapQueueItem>(Channel.UNLIMITED)
private val snapQueue = MutableSharedFlow<Long>(extraBufferCapacity = 100, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val openedSnapsIds = ConcurrentHashMap.newKeySet<Long>()
private val queuedSnaps = LinkedList<SnapQueueItem>()
private val deadLetterQueue = mutableListOf<SnapQueueItem>()
private var engineJob: Job? = null
private val engineDispatcher = Dispatchers.Default.limitedParallelism(1)
@@ -78,19 +79,22 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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 var wakeLockCooldownJob: Job? = null
private var currentStatusText = "Monitoring..."
private var currentSpeedText = "Full Speed"
private var lastNotificationUpdate = 0L
private var lastNotificationStateHash = 0
private val notificationUpdateDelay = 1000L
private val pendingNotificationUpdate = AtomicBoolean(false)
private val snapTimestamps = LinkedList<Long>()
private var lastConversationId: String? = null
private var lastQueueActivity = System.currentTimeMillis()
private val isSaving = AtomicBoolean(false)
private val needsSaving = AtomicBoolean(false)
private val lastSaveTime = AtomicLong(System.currentTimeMillis())
private var isThermalThrottled = false
private var lastThermalThrottleAt = 0L
private var actionReceiver: BroadcastReceiver? = null
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")
@@ -99,7 +103,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
val now = System.currentTimeMillis(); val window = 5000L
synchronized(snapTimestamps) {
snapTimestamps.removeIf { now - it > window }
// Smoother calculation for high-frequency bursts
return if (snapTimestamps.isEmpty()) 0.0 else (snapTimestamps.size.toDouble() / (window / 1000.0))
}
}
@@ -110,10 +113,11 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
override fun init() {
if (autoOpenConfig.globalState != true) return
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 {
@@ -121,6 +125,13 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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 -> param.setResult(null) }
@@ -129,6 +140,27 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
}
// Background Watchdog: Periodically verifies engine health
this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.Default) {
while (isActive && engineActive.get()) {
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(30000) // 30s watchdog cycle
}
}
setupReceivers()
startEngineWorker()
setupDetector()
@@ -136,90 +168,106 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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
snapQueue.collect {
while (engineActive.get()) {
val item = synchronized(queuedSnaps) { if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null } ?: break
updateStatusNotification()
if (!validateEnvironmentalConstraints()) {
synchronized(queuedSnaps) { queuedSnaps.remove(item) }
continue
}
while (isPaused.get() && engineActive.get()) {
currentStatusText = "Paused"; updateStatusNotification(); delay(500)
}
if (!engineActive.get()) break
// 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()
if (!validateEnvironmentalConstraints()) {
synchronized(queuedSnaps) { queuedSnaps.add(0, item) }
continue
}
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())
// Process at natural network speed when safety is disabled
val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue<Int>).get().toLong()
if (isSafe) {
delay(Random.nextLong(baseDelay, baseDelay + 200))
} else {
if (baseDelay > 0) delay(baseDelay)
}
if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) {
currentStatusText = "Monitoring..."
updateStatusNotification()
saveQueueToDisk() // Batch complete save
startWakeLockCooldown()
}
}
}
}
}
private suspend fun processSnapItem(item: SnapQueueItem) {
// Verify database state on background thread before processing
val dbMessage = withContext(Dispatchers.IO) { this@AutoOpenSnaps.context.database.getConversationMessageFromId(item.messageId) }
if (dbMessage?.isViewedByUser == 1) {
return
}
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) {
if (messaging.conversationManager == null) {
runCatching { this@AutoOpenSnaps.context.messagingBridge.triggerSessionStart() }
delay(1000)
delay(1000)
}
success = withContext(Dispatchers.IO) { performOpen(item) }
success = 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()
// Industrial Interval Check: Only write to disk once every 10 minutes during floods
if (System.currentTimeMillis() - lastSaveTime.get() > 600000) {
saveQueueToDisk()
lastSaveTime.set(System.currentTimeMillis())
}
val duration = System.currentTimeMillis() - startTime
averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong())
triggerLazySave(); break
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) }
synchronized(openedSnapsIds) { openedSnapsIds.remove(item.messageId) }
synchronized(deadLetterQueue) { if (deadLetterQueue.size < 100) deadLetterQueue.add(item) else { deadLetterQueue.removeAt(0); deadLetterQueue.add(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) }
return withContext(Dispatchers.Main) {
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) }
}
}
}
@@ -230,17 +278,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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 -> {
else -> {
val thermalActive = (autoOpenConfig.thermalProtection as PropertyValue<Boolean>).get() && isThermalThrottled
currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed"
return true
return true
}
}
updateStatusNotification()
@@ -252,52 +300,76 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe
val message = event.message
// 1. Basic Filters & Self-Check
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 conversationId = message.messageDescriptor?.conversationId?.toString() ?: 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
// 2. Memory Gating: Prevent processing the same session snap multiple times
if (openedSnapsIds.contains(clientMessageId)) return@subscribe
// 3. Database Authority: Immediate check to see if snap is already opened
val dbMessage = this@AutoOpenSnaps.context.database.getConversationMessageFromId(clientMessageId)
if (dbMessage?.isViewedByUser == 1) return@subscribe
// 4. Temporal Gating: Ignore ancient unread snaps (fixes 'Ghost Storm' during sync)
val now = System.currentTimeMillis()
val messageTime = message.messageMetadata?.createdAt ?: 0L
if (now - messageTime > 28_800_000L) { // 8-hour window
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)
}
synchronized(queuedSnaps) { queuedSnaps.add(item) }
snapQueue.tryEmit(System.currentTimeMillis())
acquireWakeLock(); updateStatusNotification()
}
}
private fun saveQueueToDisk() {
prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) }
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 restorePersistence() {
val savedStartTime = prefs.getLong(PREF_SESSION_START, 0)
if (System.currentTimeMillis() - savedStartTime > 21600000) return
val now = System.currentTimeMillis()
if (now - savedStartTime > 21600000) return
totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime)
val savedQueueJson = prefs.getString(PREF_SAVED_QUEUE, null)
if (!savedQueueJson.isNullOrBlank()) {
runCatching {
val restored: List<SnapQueueItem> = gson.fromJson(savedQueueJson, object : TypeToken<List<SnapQueueItem>>() {}.type)
synchronized(queuedSnaps) { queuedSnaps.addAll(restored.filter { (now - it.timestamp) < 3600000 }) }
}
}
}
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
// Check for any available network with a WiFi or Ethernet transport
return cm.allNetworks.any { network ->
cm.getNetworkCapabilities(network)?.let { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
} == true
}
}
private fun isDeviceIdle(): Boolean = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode
@@ -307,12 +379,21 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
private fun acquireWakeLock() {
wakeLockCooldownJob?.cancel()
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 startWakeLockCooldown() {
wakeLockCooldownJob?.cancel()
wakeLockCooldownJob = this@AutoOpenSnaps.context.coroutineScope.launch {
delay(30000)
releaseWakeLock()
}
}
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) })
@@ -321,9 +402,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
private fun updateStatusNotification(force: Boolean = false) {
val now = System.currentTimeMillis()
if (!isScreenOn.get() && !force) return
if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) {
if (pendingNotificationUpdate.compareAndSet(false, true)) {
this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() }
this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() }
}
return
}
@@ -335,31 +417,35 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
val processed = sessionProcessed.get()
val total = totalProcessed.get()
val remaining = synchronized(queuedSnaps) { queuedSnaps.size }
// Industrial State Hashing: Prevent redundant redraws and CPU wakeups
val currentStateHash = Objects.hash(processed, total, remaining, currentStatusText, isPaused.get())
if (currentStateHash == lastNotificationStateHash && remaining == 0) return
lastNotificationStateHash = currentStateHash
val isWorking = remaining > 0
val speed = if (isWorking) getSnapsPerSecond() else 0.0
lastNotificationUpdate = System.currentTimeMillis(); pendingNotificationUpdate.set(false)
val sessionTotal = processed + remaining
val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0
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.setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else if (!isWorking) android.R.drawable.ic_popup_sync else android.R.drawable.ic_media_play)
builder.setContentTitle("Auto-Open: $currentStatusText")
val isCompact = (autoOpenConfig.compactNotification as PropertyValue<Boolean>).get()
if (isWorking) {
builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)")
builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta")
if (isCompact) {
builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta")
} else {
builder.setSubText("")
}
builder.setProgress(sessionTotal, processed, false)
} else {
builder.setContentText("$processed Opened Today │ $total Total")
@@ -383,10 +469,10 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
val speedNotion = if (isWorking) currentSpeedText else "Idle"
val speedValue = "${String.format(Locale.US, "%.1f", speed)}/s"
append("└─ Speed: $speedNotion ($speedValue)\n")
append("└─ Speed: $speedNotion ($speedValue)")
if ((autoOpenConfig.showQueuePreview as PropertyValue<Boolean>).get()) {
append("\nQUEUE PREVIEW\n")
append("\n\nQUEUE PREVIEW\n")
if (isWorking && remaining > 0) {
recentSnaps.reversed().forEach { item ->
append("• ${item.senderName} │ ${item.conversationType} (${item.contentType})\n")
@@ -400,21 +486,22 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
builder.setStyle(bigTextStyle)
}
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
}
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 setupReceivers() {
val actionReceiver = object : BroadcastReceiver() {
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_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) }
Intent.ACTION_SCREEN_OFF -> { isScreenOn.set(false) }
Intent.ACTION_BATTERY_CHANGED -> {
val temp = intent.getIntExtra("temperature", 0) / 10f
if (temp >= 40f && !isThermalThrottled) { isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis() }
@@ -423,21 +510,57 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
}
}
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)
val filter = IntentFilter().apply {
addAction(ACTION_PAUSE_RESUME)
addAction(ACTION_CLEAR_QUEUE)
addAction(ACTION_STOP_ENGINE)
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
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 recordSpeedTimestamp() { synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 250) snapTimestamps.removeFirst() } }
private fun shutdownFeature() {
private fun shutdownFeature() {
engineActive.set(false)
snapChannel.close()
engineJob?.cancel()
releaseWakeLock()
cancelStatusNotification()
saveQueueToDisk()
// Permanently disable the feature in settings
autoOpenConfig.globalState = false
this@AutoOpenSnaps.context.coroutineScope.launch {
runCatching {
val field = context::class.java.getDeclaredField("_config").apply { isAccessible = true }
val modConfig = (field.get(context) as Lazy<*>).value as ModConfig
modConfig.writeConfig()
}
}
// Surgical clean-up: release resources and listeners
actionReceiver?.let {
runCatching { this@AutoOpenSnaps.context.androidContext.unregisterReceiver(it) }
}
actionReceiver = null
wakeLockCooldownJob?.cancel()
// Grace period for WakeLock release
this@AutoOpenSnaps.context.coroutineScope.launch {
delay(60000)
releaseWakeLock()
}
// Show final "Stopped" notice
val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status")
.setOngoing(false)
.setSmallIcon(android.R.drawable.ic_menu_close_clear_cancel)
.setContentTitle("Auto-Open")
.setContentText("Auto-Open Engine Disabled. Re-enable in settings.")
runCatching { notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) }.onFailure { logError("Failed to update notification (System not ready)", it) }
}
private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
fun getInterface(): AutoOpenInterface {
@@ -450,7 +573,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
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" }
private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" }
}
data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String, val timestamp: Long = System.currentTimeMillis())

View File

@@ -144,8 +144,24 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
}
private fun getRandomizedProfile(): RandomizedDeviceProfile {
val generationToken = context.config.experimental.spoof.randomizeDeviceProfile.profileGenerationToken.getNullable()
return randomizedProfile ?: RandomizedDeviceProfileStore
if (randomizedProfile != null) return randomizedProfile!!
val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile
val configProfileJson = spoofConfig.profileData.getNullable()
if (!configProfileJson.isNullOrBlank()) {
runCatching {
val profile = RandomizedDeviceProfile.fromJson(configProfileJson)
randomizedProfile = profile
context.log.verbose("Using restored randomized device profile from config")
return profile
}.onFailure {
context.log.warn("Failed to parse restored device profile from config, generating fresh one: ${it.message}")
}
}
val generationToken = spoofConfig.profileGenerationToken.getNullable()
return RandomizedDeviceProfileStore
.getOrCreate(context.androidContext, context.log, generationToken)
.also { profile ->
randomizedProfile = profile
@@ -155,18 +171,23 @@ class DeviceSpooferHook : Feature("Device Spoofer") {
private fun persistRandomizedProfileSnapshot(profile: RandomizedDeviceProfile) {
val spoofConfig = context.config.experimental.spoof.randomizeDeviceProfile
val profileJson = profile.toJson().toString()
val snapshot = profile.toJson().toString(2)
if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot) return
if (spoofConfig.currentProfileSnapshot.getNullable() == snapshot && spoofConfig.profileData.getNullable() == profileJson) return
spoofConfig.currentProfileSnapshot.set(snapshot)
spoofConfig.profileData.set(profileJson) // Synchronize raw profile data for multi-process persistence
runCatching {
val field = context.javaClass.getDeclaredField("_config\$delegate")
field.isAccessible = true
val lazyConfig = field.get(context) as Lazy<*>
val modConfig = lazyConfig.value as? ModConfig ?: return@runCatching
modConfig.writeConfig(dispatchConfigListener = false)
context.log.verbose("Persisted randomized device profile snapshot to config")
context.log.verbose("Persisted randomized device profile data to config")
}.onFailure {
context.log.warn("Failed to persist randomized device profile snapshot: ${it.message}")
context.log.warn("Failed to persist randomized device profile: ${it.message}")
}
}

View File

@@ -70,33 +70,37 @@ class MediaFilePicker : Feature("Media File Picker") {
private const val SNAP_CHUNK_DURATION_MS = 10_000L
private val queuedSplitItems = ArrayDeque<Any>()
private val queuedSplitItemIds = ArrayDeque<String>()
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
private val queuedSplitCleanupItems = mutableMapOf<String, PreparedMediaItem>()
private var originalUnsplitItem: Any? = null
private var reusableOriginalItem: Any? = null
private var queuedOverrideType: String? = null
private var queuedOverrideSnapDurationMs: Int? = null
private var bypassSplitOnce = false
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
private var cleanupItemHandler: ((String) -> Unit)? = null
private var cleanupItemHandler: ((String, String?) -> Unit)? = null
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
fun hasReusableOriginalItem(): Boolean = reusableOriginalItem != null
fun setQueuedOverrideType(value: String?) {
fun setQueuedOverrideType(value: String?, snapDurationMs: Int? = 10_000) {
queuedOverrideType = value
queuedOverrideSnapDurationMs = if (value == null) null else snapDurationMs
}
fun getQueuedOverrideType(): String? = queuedOverrideType
fun getQueuedOverrideSnapDurationMs(): Int? = queuedOverrideSnapDurationMs
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
if (deleteTempItems) {
val cleanup = cleanupItemHandler
queuedSplitCleanupUris.values.toList().forEach { uri ->
cleanup?.invoke(uri)
queuedSplitCleanupItems.values.toList().forEach { item ->
cleanup?.invoke(item.uri, item.filePath)
}
}
queuedSplitItems.clear()
queuedSplitItemIds.clear()
queuedSplitCleanupUris.clear()
queuedSplitCleanupItems.clear()
originalUnsplitItem = null
queuedOverrideType = null
queuedOverrideSnapDurationMs = null
}
fun sendReusableOriginalItem(): Boolean {
val item = reusableOriginalItem ?: return false
@@ -110,30 +114,34 @@ class MediaFilePicker : Feature("Media File Picker") {
items.drop(1).forEach { queuedSplitItems.addLast(it) }
preparedItems.forEach {
queuedSplitItemIds.addLast(it.itemId)
queuedSplitCleanupUris[it.itemId] = it.uri
queuedSplitCleanupItems[it.itemId] = it
}
}
fun sendOriginalUnsplitItem(): Boolean {
val item = originalUnsplitItem ?: return false
val overrideType = queuedOverrideType
val overrideSnapDurationMs = queuedOverrideSnapDurationMs
clearQueuedSplitItems(deleteTempItems = true)
queuedOverrideType = overrideType
queuedOverrideSnapDurationMs = overrideSnapDurationMs
bypassSplitOnce = true
val sender = sendSingleItemHandler ?: return false
return sender(item)
}
fun handleCurrentQueuedItemSuccess(): Boolean {
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
cleanupItemHandler?.invoke(uri)
queuedSplitCleanupItems.remove(itemId)?.let { item ->
cleanupItemHandler?.invoke(item.uri, item.filePath)
}
}
if (queuedSplitItems.isEmpty()) {
queuedOverrideType = null
queuedOverrideSnapDurationMs = null
return false
}
val next = queuedSplitItems.removeFirstOrNull() ?: run {
queuedOverrideType = null
queuedOverrideSnapDurationMs = null
return false
}
val sender = sendSingleItemHandler ?: return false
@@ -151,7 +159,8 @@ class MediaFilePicker : Feature("Media File Picker") {
private data class PreparedMediaItem(
val itemId: String,
val durationMs: Long,
val uri: String
val uri: String,
val filePath: String? = null
)
private fun splitVideoIntoChunks(
@@ -301,7 +310,12 @@ class MediaFilePicker : Feature("Media File Picker") {
runCatching { resolver.delete(uri, null, null) }
}
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
return PreparedMediaItem(
itemId = itemId,
durationMs = durationMs,
uri = uri.toString(),
filePath = file.absolutePath
)
}
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
@@ -423,11 +437,18 @@ class MediaFilePicker : Feature("Media File Picker") {
false
}
}
cleanupItemHandler = { uriString ->
cleanupItemHandler = { uriString, filePath ->
runCatching {
// Industrial Cleanup: Direct file deletion is the gold standard for Android 14
filePath?.let { path ->
val file = File(path)
if (file.exists()) file.delete()
}
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
}.onFailure {
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
if (it.message?.contains("no access") == false) {
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
}
}
}
if (sendItemsHookedHandler === handlerInstance) return@hook

View File

@@ -2,7 +2,6 @@ package me.eternal.purrfectsnap.core.features.impl.global
import android.os.SystemClock
import android.view.View
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.ui.dispatchSyntheticTap
@@ -35,7 +34,6 @@ class AdBlockFix : Feature("AdBlockFix") {
hookFeedEntryTracking()
hookMessagingFeedCallbacks()
hookChatFeedRowSuppression()
hookOperaAutoSkip()
}
@@ -45,7 +43,7 @@ class AdBlockFix : Feature("AdBlockFix") {
val conversationId = feedEntry.getObjectFieldOrNull("mConversationId")?.let(::SnapUUID)?.toString()
?: return@hookConstructor
if (isCampaignFeedEntry(feedEntry) || isChatAdShareFeedEntry(feedEntry)) {
if (isCampaignFeedEntry(feedEntry)) {
adConversationIds.add(conversationId)
}
}
@@ -123,36 +121,20 @@ class AdBlockFix : Feature("AdBlockFix") {
}
}
private fun hookChatFeedRowSuppression() {
context.event.subscribe(BindViewEvent::class) { event ->
val modelDump = event.prevModel.toString()
event.friendFeedItem { conversationId ->
if (adConversationIds.contains(conversationId) || isChatAdShareModel(modelDump)) {
hideBoundChatFeedRow(event.view)
}
}
}
}
private fun hideBoundChatFeedRow(view: View) {
view.hideViewCompletely()
(view.parent as? View)?.hideViewCompletely()
(view.parent?.parent as? View)?.hideViewCompletely()
}
private fun hookOperaAutoSkip() {
onNextActivityCreate {
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
val resolvedMethod = methodName.get() ?: return@forEach
classReference.get()?.hook(resolvedMethod, HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val viewState = runCatching {
param.thisObject<Any>().getObjectField(viewStateField.get()!!)?.toString()
instance::class.java.methods.firstOrNull { it.name.contains("ViewState") || it.name == "g" }?.invoke(instance)?.toString()
}.getOrNull() ?: return@hook
if (viewState != "FULLY_DISPLAYED") return@hook
val layerList = runCatching {
param.thisObject<Any>().getObjectField(layerListField.get()!!) as? ArrayList<*>
instance::class.java.methods.firstOrNull { it.name.contains("LayerList") || it.name == "l" }?.invoke(instance) as? ArrayList<*>
}.getOrNull() ?: return@hook
val paramMap = runCatching {
layerList.map { Layer(it).paramMap }.firstOrNull()
@@ -209,25 +191,6 @@ class AdBlockFix : Feature("AdBlockFix") {
?.getObjectFieldOrNull("mCampaignMetadata") != null
}
private fun isChatAdShareFeedEntry(feedEntry: Any): Boolean {
val interactionDump = feedEntry.getObjectFieldOrNull("mInteractionInfo")?.toString().orEmpty()
val displayDump = feedEntry.getObjectFieldOrNull("mDisplayInfo")?.toString().orEmpty()
val combined = "$interactionDump $displayDump"
return isChatAdShareModel(combined)
}
private fun isChatAdShareModel(modelDump: String): Boolean {
if (modelDump.isBlank()) return false
return modelDump.contains("CHAT_AD_SHARE") ||
modelDump.contains("AD_SHARE") ||
modelDump.contains("ChatAd") ||
modelDump.contains("chat_ad_share") ||
modelDump.contains("chat_sponsored_snap") ||
modelDump.contains("CommonAttachmentViewModel") ||
modelDump.contains("visibilityFeedbackURL") ||
modelDump.contains("pageLoadPingURL")
}
private fun isSpotlightCommercialPage(paramMap: ParamMap): Boolean {
val snapSource = paramMap["SNAP_SOURCE"]?.toString()
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY") {

View File

@@ -17,67 +17,59 @@ class SnapchatPlus: Feature("SnapchatPlus") {
override fun init() {
val snapchatPlusTier = context.config.global.snapchatPlus.getNullable()
if (snapchatPlusTier == null || snapchatPlusTier == "not_subscribed") return
if (snapchatPlusTier != null) {
context.mappings.useMapper(PlusSubscriptionMapper::class) {
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().dataBuilder {
//subscription tier
if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) {
set(tierField.getAsString()!!, when (snapchatPlusTier) {
"not_subscribed" -> "NO_ACCESS"
"basic" -> "SNAPCHAT_PLUS"
"ad_free" -> "SNAPCHAT_PLUS_AD_FREE"
else -> "SNAPCHAT_PLUS"
})
} else {
set(tierField.getAsString()!!, when (snapchatPlusTier) {
"not_subscribed" -> 1
"basic" -> 2
"ad_free" -> 3
else -> 2
})
}
// Pre-calculate custom purchase date to eliminate main thread lag
val customPurchaseDateRaw = context.config.global.snapchatPlusPurchaseDate.get().trim()
val customPurchaseDateMillis = if (customPurchaseDateRaw.isNotEmpty()) {
runCatching {
LocalDate.parse(customPurchaseDateRaw, DateTimeFormatter.ISO_LOCAL_DATE)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}.getOrNull()
} else (System.currentTimeMillis() - 7776000000L) // 3 months fallback
//subscription status
set(statusField.getAsString()!!, 2)
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)
context.mappings.useMapper(PlusSubscriptionMapper::class) {
classReference.get()?.hookConstructor(HookStage.AFTER) { param ->
param.thisObject<Any>().dataBuilder {
//subscription tier
if (get<Any>(tierField.getAsString()!!)?.javaClass?.isEnum == true) {
set(tierField.getAsString()!!, when (snapchatPlusTier) {
"not_subscribed" -> "NO_ACCESS"
"basic" -> "SNAPCHAT_PLUS"
"ad_free" -> "SNAPCHAT_PLUS_AD_FREE"
else -> "SNAPCHAT_PLUS"
})
} else {
set(tierField.getAsString()!!, when (snapchatPlusTier) {
"not_subscribed" -> 1
"basic" -> 2
"ad_free" -> 3
else -> 2
})
}
//subscription status
set(statusField.getAsString()!!, 2)
set(
originalSubscriptionTimeMillisField.getAsString()!!,
customPurchaseDateMillis
)
set(expirationTimeMillisField.getAsString()!!, expirationTimeMillis)
}
}
}
// Force enable all premium features in the catalog
if (context.config.experimental.hiddenSnapchatPlusFeatures.get()) {
findClass("com.snap.plus.FeatureCatalog").methods.last {
!it.name.contains("init") &&
it.parameterTypes.isNotEmpty() &&
it.parameterTypes[0].name != "java.lang.Boolean"
}.hook(HookStage.BEFORE) { param ->
val instance = param.thisObject<Any>()
val firstArg = param.argNullable<Any>(0) ?: return@hook
instance.findFieldNamesByType(firstArg::class.java).forEach { fieldName ->
instance.setObjectField(fieldName, firstArg)
runCatching {
val featureCatalogClass = findClass("com.snap.plus.FeatureCatalog")
featureCatalogClass.hook("isFeatureEnabled", HookStage.BEFORE) { param ->
param.setResult(true)
}
context.log.verbose("Successfully unlocked premium Snapchat features")
}
}
}

View File

@@ -1,7 +1,13 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -28,10 +34,12 @@ import androidx.compose.ui.text.input.KeyboardType
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.bridge.task.TaskListener
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.config.PropertyValue
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.ModContext
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
@@ -64,38 +72,126 @@ import kotlin.time.toDuration
class SendOverride : Feature("Send Override") {
companion object {
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
private const val CONTINUOUS_SEND_CHANNEL_ID = "continuous_send_status"
private const val STATUS_NOTIFICATION_ID = 54322
private const val COMPLETION_NOTIFICATION_ID = 54323
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.CONTINUOUS_SEND_PAUSE_RESUME"
const val ACTION_STOP = "me.eternal.purrfectsnap.CONTINUOUS_SEND_STOP"
private val internalMultipartSend = ThreadLocal.withInitial { false }
private var queuedOriginalItemRepeatCount = 0
private var queuedOriginalItemRepeatOverrideType: String? = null
private var queuedOriginalItemRepeatSnapDurationMs: Int? = null
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) {
// Notification & Loop Tracking
private var totalRepeatCount = 0
private var processedRepeatCount = 0
private var currentRecipientName: String = "Unknown"
private val isPaused = java.util.concurrent.atomic.AtomicBoolean(false)
private val isStopped = java.util.concurrent.atomic.AtomicBoolean(false)
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
queuedOriginalItemRepeatCount = repeatCount
queuedOriginalItemRepeatOverrideType = overrideType
MediaFilePicker.setQueuedOverrideType(overrideType)
queuedOriginalItemRepeatSnapDurationMs = snapDurationMs
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
}
private fun clearQueuedOriginalItemRepeats() {
queuedOriginalItemRepeatCount = 0
queuedOriginalItemRepeatOverrideType = null
queuedOriginalItemRepeatSnapDurationMs = null
}
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
if (queuedOriginalItemRepeatCount <= 0) {
private fun updateContinuousSendNotification(context: ModContext) {
if (!engineActive.get()) return
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val remaining = queuedOriginalItemRepeatCount
val processed = processedRepeatCount
val total = totalRepeatCount
val isWorking = remaining > 0 && !isStopped.get() && engineActive.get()
if (!isWorking) {
notificationManager.cancel(STATUS_NOTIFICATION_ID)
showCompletionNotification(context, processed, total)
return
}
val progressPercent = if (total > 0) (processed * 100) / total else 0
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setSmallIcon(android.R.drawable.ic_popup_sync)
.setColor(0xFF3498DB.toInt())
.setContentTitle("Sending Snaps to $currentRecipientName")
.setContentText("Progress: $processed / $total ($progressPercent%)")
.setSubText("$processed / $total")
.setProgress(total, processed, false)
val pauseResumeLabel = if (isPaused.get()) "Resume" else "Pause"
builder.addAction(Notification.Action.Builder(null, pauseResumeLabel, createPendingIntent(context, ACTION_PAUSE_RESUME)).build())
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(context, ACTION_STOP)).build())
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
}
private fun showCompletionNotification(context: ModContext, sent: Int, total: Int) {
val isError = sent < total && !isStopped.get()
val title = when {
isStopped.get() -> "Continuous Send Stopped"
isError -> "Continuous Send Failed"
else -> "Continuous Send Finished"
}
val content = "Sent $sent / $total snaps to $currentRecipientName"
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val builder = Notification.Builder(context.androidContext, CONTINUOUS_SEND_CHANNEL_ID)
.setSmallIcon(if (isError) android.R.drawable.stat_notify_error else android.R.drawable.checkbox_on_background)
.setColor(if (isError) 0xFFE74C3C.toInt() else 0xFF2ECC71.toInt())
.setContentTitle(title)
.setContentText(content)
.setAutoCancel(true)
notificationManager.notify(COMPLETION_NOTIFICATION_ID, builder.build())
}
private fun createPendingIntent(context: ModContext, action: String): PendingIntent {
val intent = Intent(action).setPackage(context.androidContext.packageName)
return PendingIntent.getBroadcast(
context.androidContext,
action.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun handleQueuedOriginalItemRepeatSuccess(context: ModContext): Boolean {
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return false
}
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return false
}
val snapDurationMs = queuedOriginalItemRepeatSnapDurationMs
processedRepeatCount++
queuedOriginalItemRepeatCount--
MediaFilePicker.setQueuedOverrideType(overrideType)
updateContinuousSendNotification(context)
MediaFilePicker.setQueuedOverrideType(overrideType, snapDurationMs)
val result = MediaFilePicker.sendReusableOriginalItem()
if (!result) {
queuedOriginalItemRepeatCount++
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
return result
}
@@ -112,6 +208,20 @@ class SendOverride : Feature("Send Override") {
private val backgroundHookLock = Any()
private var backgroundHookRefs = 0
private var backgroundHooks: List<Hooker.HookHandle>? = null
private fun createContinuousNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.androidContext.getSystemService(NotificationManager::class.java)
val channel = NotificationChannel(
CONTINUOUS_SEND_CHANNEL_ID,
"Continuous Send",
NotificationManager.IMPORTANCE_LOW
)
channel.description = "Progress status for continuous snap sending"
notificationManager.createNotificationChannel(channel)
}
}
private fun acquireScheduledSendBackground(): () -> Unit {
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
var enableFailed = false
@@ -192,7 +302,35 @@ class SendOverride : Feature("Send Override") {
@OptIn(ExperimentalLayoutApi::class)
override fun init() {
createNotificationChannel()
createContinuousNotificationChannel()
val actionReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
when (intent?.action) {
ACTION_PAUSE_RESUME -> {
isPaused.set(!isPaused.get())
updateContinuousSendNotification(context)
}
ACTION_STOP -> {
isStopped.set(true)
if (isPaused.get()) {
isPaused.set(false)
}
updateContinuousSendNotification(context)
}
}
}
}
val filter = IntentFilter().apply {
addAction(ACTION_PAUSE_RESUME)
addAction(ACTION_STOP)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.androidContext.registerReceiver(actionReceiver, filter)
}
val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get()
var postSavePolicy: Int? = null
@@ -727,10 +865,10 @@ class SendOverride : Feature("Send Override") {
completionCallback: Any?
): Boolean {
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
val mediaCount = (sourceReader.followPath(3) as? ProtoReader)?.getCount(3) ?: 0
if (overrideType != "ORIGINAL" && mediaCount > 1) {
val mediaBuffers = mutableListOf<ByteArray>()
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
(sourceReader.followPath(3) as? ProtoReader)?.eachBuffer { id, buffer ->
if (id == 3) mediaBuffers.add(buffer)
}
if (mediaBuffers.isEmpty()) return false
@@ -802,22 +940,54 @@ class SendOverride : Feature("Send Override") {
if (repeatCount <= 0) return false
fun sendIteration(index: Int) {
val callback = if (index == repeatCount - 1) {
originalCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
context.coroutineScope.launch {
while (isPaused.get() && !isStopped.get()) {
delay(500)
}
if (isStopped.get()) {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
return@launch
}
val callback = CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendIteration(index + 1)
processedRepeatCount++
queuedOriginalItemRepeatCount--
updateContinuousSendNotification(context)
if (index < repeatCount - 1) {
sendIteration(index + 1)
} else {
// Batch Finished: Trigger original Snapchat callback
originalCallback?.let { cb ->
runCatching {
val method = cb.javaClass.methods.firstOrNull { it.name == "onSuccess" }
if (method != null) {
if (method.parameterCount == 0) {
method.invoke(cb)
} else {
// Pass null for all required parameters to safely trigger the completion UI
method.invoke(cb, *arrayOfNulls<Any>(method.parameterCount))
}
}
}.onFailure { context.log.error("Failed to trigger completion handshake", it) }
}
}
}
.override("onError", shouldUnhook = false) {
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
.build()
}
val preparedContent = createMessageContentFromOriginal()
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send")
if (index > 0) delay(1000) // Human-like delay
val preparedContent = createMessageContentFromOriginal()
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send")
}
}
}
@@ -830,15 +1000,21 @@ class SendOverride : Feature("Send Override") {
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
}
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
val queuedOverrideType = MediaFilePicker.getQueuedOverrideType()
val resolvedOverrideType = queuedOverrideType
?: configOverrideType?.takeIf { it != "always_ask" }
val resolvedSnapDurationMs = if (queuedOverrideType != null) {
MediaFilePicker.getQueuedOverrideSnapDurationMs()
} else {
10000
}
fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) {
sendEvent.addCallbackResult("onSuccess") {
context.runOnUiThread {
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
val handledRepeat = if (!handledSplit) {
handleQueuedOriginalItemRepeatSuccess()
handleQueuedOriginalItemRepeatSuccess(context)
} else {
false
}
@@ -858,7 +1034,7 @@ class SendOverride : Feature("Send Override") {
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
attachQueuedRepeatCallbacks(event)
}
if (sendMedia(resolvedOverrideType, 10000)) {
if (sendMedia(resolvedOverrideType, resolvedSnapDurationMs)) {
if (event.canceled) invokeOriginalAndRestoreResult(event)
}
return@subscribe
@@ -866,7 +1042,6 @@ class SendOverride : Feature("Send Override") {
context.runOnUiThread {
val recipientNameForTask = recipientName
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
@@ -1274,6 +1449,11 @@ class SendOverride : Feature("Send Override") {
}
Button(onClick = {
val finalSelectedType = selectedType
val selectedSnapDurationMs = if (finalSelectedType != "SAVEABLE_SNAP") {
convertDuration(customDuration)
} else {
null
}
val repeatCount = if (continuousSendEnabled) {
continuousSendCount.toIntOrNull()?.takeIf { it > 0 }
} else {
@@ -1295,13 +1475,13 @@ class SendOverride : Feature("Send Override") {
}
alertDialog.dismiss()
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
MediaFilePicker.setQueuedOverrideType(finalSelectedType, selectedSnapDurationMs)
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(null)
}
return@Button
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
MediaFilePicker.setQueuedOverrideType(finalSelectedType, selectedSnapDurationMs)
event.addCallbackResult("onSuccess") {
context.runOnUiThread {
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
@@ -1361,7 +1541,7 @@ class SendOverride : Feature("Send Override") {
if (sendRepeatedMediaManual(
repeatCount,
finalSelectedType,
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
selectedSnapDurationMs
)) {
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
context.inAppOverlay.showStatusToast(
@@ -1410,22 +1590,32 @@ class SendOverride : Feature("Send Override") {
}
} else {
if (repeatCount == 1) {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
invokeOriginalAndRestoreResult(event)
}
} else if (MediaFilePicker.hasReusableOriginalItem()) {
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType)
totalRepeatCount = repeatCount
processedRepeatCount = 1
currentRecipientName = recipientNameForTask
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
attachQueuedRepeatCallbacks(event)
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
invokeOriginalAndRestoreResult(event)
} else {
clearQueuedOriginalItemRepeats()
updateContinuousSendNotification(context)
}
} else {
totalRepeatCount = repeatCount
processedRepeatCount = 0
queuedOriginalItemRepeatCount = repeatCount
currentRecipientName = recipientNameForTask
sendRepeatedMediaManual(
repeatCount,
finalSelectedType,
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
selectedSnapDurationMs
)
}
}

View File

@@ -146,13 +146,17 @@ class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.ME
it.messageId = uniqueMessageIdentifier
it.conversationId = conversationId
it.userId = event.message.senderId.toString()
it.username = usernameCache.getOrPut(it.userId) {
context.database.getFriendInfo(it.userId)?.mutableUsername ?: it.userId
}
it.username = usernameCache[it.userId]
?: context.database.getFriendInfo(it.userId)?.mutableUsername?.also { resolvedUsername ->
usernameCache[it.userId] = resolvedUsername
}
?: it.userId
it.sendTimestamp = event.message.messageMetadata?.createdAt ?: System.currentTimeMillis()
it.groupTitle = groupTitleCache.getOrPut(conversationId) {
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName ?: conversationId
}
it.groupTitle = groupTitleCache[conversationId]
?: context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName?.also { resolvedGroupTitle ->
groupTitleCache[conversationId] = resolvedGroupTitle
}
?: conversationId
it.messageData = context.gson.toJson(messageInstance).toByteArray(Charsets.UTF_8)
}
)

View File

@@ -3,84 +3,36 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.animation.ValueAnimator
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.database.sqlite.SQLiteDatabase
import android.hardware.camera2.CaptureRequest
import android.media.MediaRecorder
import android.os.Build
import android.os.HandlerThread
import android.os.Process
import android.util.Base64
import android.transition.Transition
import android.util.Range
import android.view.View
import android.view.TextureView
import android.view.ViewPropertyAnimator
import android.view.WindowManager
import android.view.animation.Animation
import android.widget.OverScroller
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import java.io.File
import java.lang.Thread
import java.lang.reflect.Method
import java.util.LinkedHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.ThreadPoolExecutor
import com.google.gson.reflect.TypeToken
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import okhttp3.Dispatcher
import java.lang.Thread
import java.lang.reflect.Method
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.atomic.AtomicBoolean
class PerformanceMode : Feature("Performance Mode") {
companion object {
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
private const val SNAP_PREFETCH_DM_MESSAGES = 24
private const val REOPEN_WARMUP_GROUP_MESSAGES = 160
private const val REOPEN_WARMUP_DM_MESSAGES = 96
}
private data class SnapshotCell(
val type: Int,
val stringValue: String? = null,
val longValue: Long? = null,
val doubleValue: Double? = null,
val blobValue: String? = null,
)
private data class CursorSnapshot(
val columns: List<String>,
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,
val oldestOrderKey: Long?,
val newestOrderKey: Long?,
val updatedAt: Long,
val isGroup: Boolean,
)
override fun init() {
val profile = context.config.global.performanceMode.profile.getNullable() ?: return
val isMaxProfile = profile == "max"
@@ -90,7 +42,6 @@ class PerformanceMode : Feature("Performance Mode") {
Process.THREAD_PRIORITY_MORE_FAVORABLE
}
val minimumFrameRate = if (isMaxProfile) 60 else 45
val minimumRecordingFrameRate = if (isMaxProfile) 30 else 24
val durationScale = if (isMaxProfile) 0.35f else 0.55f
val recyclerViewCacheSize = if (isMaxProfile) 64 else 32
val maxRequests = if (isMaxProfile) 192 else 96
@@ -100,55 +51,19 @@ class PerformanceMode : Feature("Performance Mode") {
val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L
val maxScrollDurationMs = if (isMaxProfile) 72 else 180
val preferredRefreshRate = if (isMaxProfile) 120f else 90f
val snapMapTransitionDurationMs = if (isMaxProfile) 0L else 24L
val snapMapCameraDurationMs = if (isMaxProfile) 16L else 64L
val snapMapMoveDurationMs = if (isMaxProfile) 8L else 40L
val snapMapPrefetchZoomDelta = if (isMaxProfile) 6 else 3
val preferredJavaThreadPriority = if (isMaxProfile) Thread.NORM_PRIORITY + 2 else Thread.NORM_PRIORITY + 1
context.log.info(
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, minRecordingFps=$minimumRecordingFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate, snapMapTransitionMs=$snapMapTransitionDurationMs, snapMapCameraMs=$snapMapCameraDurationMs, snapMapMoveMs=$snapMapMoveDurationMs, snapMapPrefetchZoomDelta=$snapMapPrefetchZoomDelta, javaThreadPriority=$preferredJavaThreadPriority",
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate",
"PerformanceMode"
)
runCatching {
ValueAnimator.setFrameDelay(0L)
context.log.info("Applied ValueAnimator frame delay override: 0ms", "PerformanceMode")
}
fun firstHitLogger(name: String): (String) -> Unit {
val didLog = AtomicBoolean(false)
return { details ->
if (didLog.compareAndSet(false, true)) {
context.log.info("First hit: $name | $details", "PerformanceMode")
}
}
}
val handlerThreadConstructorLog = firstHitLogger("HandlerThread.constructor")
val handlerThreadStartLog = firstHitLogger("HandlerThread.start")
val threadStartLog = firstHitLogger("Thread.start")
val executorLog = firstHitLogger("ThreadPoolExecutor.constructor")
val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor")
val animatorLog = firstHitLogger("ValueAnimator.getDurationScale")
val recyclerCtorLog = firstHitLogger("RecyclerView.constructor")
val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter")
val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager")
val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase")
val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase")
val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate")
val overScrollerLog = firstHitLogger("OverScroller.startScroll")
val mapDialogLog = firstHitLogger("Dialog.show")
val mapViewLog = firstHitLogger("MapView.constructor")
val mapboxNetworkBlockLog = firstHitLogger("SnapMap.telemetryBlock")
val mapCameraAnimLog = firstHitLogger("SnapMap.mapAnimatorDuration")
val mapThreadLog = firstHitLogger("SnapMap.mapThread")
val mapRendererFpsLog = firstHitLogger("SnapMap.mapRendererFps")
val mapTransitionLog = firstHitLogger("SnapMap.transitionOptions")
val mapMoveLog = firstHitLogger("SnapMap.moveDuration")
fun isPerformanceSensitiveThread(name: String?): Boolean {
val normalizedName = name?.lowercase() ?: return false
return listOf("codec", "transcod", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
normalizedName.contains(it)
}
}
@@ -158,187 +73,11 @@ class PerformanceMode : Feature("Performance Mode") {
return durationMs.coerceAtMost(maxDurationMs)
}
val performanceCacheDir = File(context.androidContext.filesDir, "performance_mode_cache").apply { mkdirs() }
val chatFeedSnapshotFile = File(performanceCacheDir, "chat_feed_snapshot.json")
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()
if (raw.isBlank()) {
LinkedHashMap<String, MessageWindowState>()
} else {
context.gson.fromJson<LinkedHashMap<String, MessageWindowState>>(
raw,
object : TypeToken<LinkedHashMap<String, MessageWindowState>>() {}.type
) ?: LinkedHashMap()
}
}.getOrElse { LinkedHashMap() }
fun persistMessageWindowStates() {
runCatching {
windowStatePrefs.edit().putString("states", context.gson.toJson(messageWindowStates)).apply()
}.onFailure {
context.log.error("Failed to persist message window states", it, "PerformanceMode")
}
}
val snapshotQueryWhitespaceRegex = Regex("\\s+")
fun buildChatFeedSnapshotQueryKey(sql: String): String {
return sql.lowercase()
.replace(snapshotQueryWhitespaceRegex, " ")
.trim()
}
fun isChatFeedQuery(sql: String): Boolean {
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 {
return when (cursor.getType(index)) {
Cursor.FIELD_TYPE_NULL -> SnapshotCell(Cursor.FIELD_TYPE_NULL)
Cursor.FIELD_TYPE_INTEGER -> SnapshotCell(Cursor.FIELD_TYPE_INTEGER, longValue = cursor.getLong(index))
Cursor.FIELD_TYPE_FLOAT -> SnapshotCell(Cursor.FIELD_TYPE_FLOAT, doubleValue = cursor.getDouble(index))
Cursor.FIELD_TYPE_STRING -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
Cursor.FIELD_TYPE_BLOB -> SnapshotCell(
Cursor.FIELD_TYPE_BLOB,
blobValue = cursor.getBlob(index)
?.takeIf { it.size <= CHAT_FEED_CACHE_MAX_BLOB_BYTES }
?.let { Base64.encodeToString(it, Base64.NO_WRAP) }
)
else -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
}
}
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 snapshot
}
fun snapshotToMatrixCursor(snapshot: CursorSnapshot): MatrixCursor {
return MatrixCursor(snapshot.columns.toTypedArray(), snapshot.rows.size).also { matrixCursor ->
snapshot.rows.forEach { row ->
matrixCursor.addRow(row.map { cell ->
when (cell.type) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cell.longValue
Cursor.FIELD_TYPE_FLOAT -> cell.doubleValue
Cursor.FIELD_TYPE_BLOB -> cell.blobValue?.let { Base64.decode(it, Base64.NO_WRAP) }
else -> cell.stringValue
}
})
}
}
}
fun readSnapshot(file: File, expectedQueryKey: String): CursorSnapshot? {
return runCatching {
if (!file.exists()) return null
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, queryKey: String, snapshot: CursorSnapshot) {
runCatching {
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")
}
}
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
if (!isMaxProfile) return@subscribe
val url = event.url
if (url.contains("ami/friends")) {
invalidateChatFeedSnapshot("friends-mutation-sync")
}
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
event.canceled = true
mapboxNetworkBlockLog("url=$url")
}
}
@@ -347,7 +86,6 @@ class PerformanceMode : Feature("Performance Mode") {
val threadName = param.argNullable<String>(0)
if (!isPerformanceSensitiveThread(threadName)) return@hookConstructor
param.setArg(1, threadPriority)
handlerThreadConstructorLog("name=$threadName priority=$threadPriority")
}
HandlerThread::class.java.hook("start", HookStage.AFTER) { param ->
@@ -359,18 +97,13 @@ class PerformanceMode : Feature("Performance Mode") {
Process.setThreadPriority(tid, threadPriority)
}
}
handlerThreadStartLog("name=${thread.name} tid=${thread.threadId} priority=$threadPriority")
}
Thread::class.java.hook("start", HookStage.AFTER) { param ->
val thread = param.thisObject<Thread>()
if (!isPerformanceSensitiveThread(thread.name)) return@hook
runCatching {
thread.priority = preferredJavaThreadPriority
}
threadStartLog("name=${thread.name} priority=${thread.priority}")
if ((thread.name ?: "").contains("map", ignoreCase = true) || (thread.name ?: "").contains("mapbox", ignoreCase = true)) {
mapThreadLog("name=${thread.name} priority=${thread.priority}")
thread.priority = if (isMaxProfile) Thread.MAX_PRIORITY else Thread.NORM_PRIORITY + 1
}
}
@@ -383,7 +116,6 @@ class PerformanceMode : Feature("Performance Mode") {
}
executor.allowCoreThreadTimeOut(false)
executor.prestartAllCoreThreads()
executorLog("core=${executor.corePoolSize} max=${executor.maximumPoolSize} active=${executor.activeCount}")
}
}
@@ -392,32 +124,62 @@ class PerformanceMode : Feature("Performance Mode") {
runCatching {
dispatcher.maxRequests = maxRequests
dispatcher.maxRequestsPerHost = maxRequestsPerHost
dispatcherLog("maxRequests=${dispatcher.maxRequests} maxRequestsPerHost=${dispatcher.maxRequestsPerHost}")
}
}
ValueAnimator::class.java.hook("getDurationScale", HookStage.AFTER) { param ->
param.setResult(durationScale)
animatorLog("durationScale=$durationScale")
}
ValueAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<Long>(0)
val updated = original.coerceAtMost(maxAnimationDurationMs)
if (updated != original) {
param.setArg(0, updated)
}
}
ViewPropertyAnimator::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<Long>(0)
val updated = original.coerceAtMost(maxAnimationDurationMs)
if (updated != original) {
param.setArg(0, updated)
}
}
Transition::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<Long>(0)
val updated = original.coerceAtMost(maxAnimationDurationMs)
if (updated != original) {
param.setArg(0, updated)
}
}
Animation::class.java.hook("setDuration", HookStage.BEFORE) { param ->
val original = param.arg<Long>(0)
val updated = original.coerceAtMost(maxAnimationDurationMs)
if (updated != original) {
param.setArg(0, updated)
}
}
RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param ->
val recyclerView = param.thisObject<RecyclerView>()
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
recyclerView.overScrollMode = View.OVER_SCROLL_NEVER
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)
if (isMaxProfile) {
recyclerView.itemAnimator = null
}
recyclerCtorLog("cache=$recyclerViewCacheSize max=$isMaxProfile class=${recyclerView::class.java.name}")
}
RecyclerView::class.java.hook("setAdapter", HookStage.AFTER) { param ->
val recyclerView = param.thisObject<RecyclerView>()
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)
if (isMaxProfile) {
recyclerView.itemAnimator = null
}
recyclerAdapterLog("cache=$recyclerViewCacheSize adapter=${param.argNullable<Any>(0)?.javaClass?.name}")
}
RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param ->
@@ -426,14 +188,13 @@ class PerformanceMode : Feature("Performance Mode") {
when (layoutManager) {
is LinearLayoutManager -> {
layoutManager.isItemPrefetchEnabled = true
layoutManager.initialPrefetchItemCount = prefetchItemCount
layoutManager.initialPrefetchItemCount = prefetchItemCount.coerceAtLeast(12)
}
is StaggeredGridLayoutManager -> {
layoutManager.isItemPrefetchEnabled = true
layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
}
}
recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount")
}
fun SQLiteDatabase.applyPerformancePragmas() {
@@ -446,28 +207,21 @@ class PerformanceMode : Feature("Performance Mode") {
}
SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param ->
(param.getResult() as? SQLiteDatabase)?.also {
it.applyPerformancePragmas()
sqliteOpenLog("path=${param.argNullable<Any>(0)}")
}
(param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas()
}
SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param ->
(param.getResult() as? SQLiteDatabase)?.also {
it.applyPerformancePragmas()
sqliteCreateLog("path=${param.argNullable<Any>(0)}")
}
(param.getResult() as? SQLiteDatabase)?.applyPerformancePragmas()
}
MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param ->
val currentRate = param.arg<Int>(0)
val applied = currentRate
.coerceAtLeast(minimumRecordingFrameRate)
.coerceAtLeast(if (isMaxProfile) 30 else 24)
.coerceAtMost(if (isMaxProfile) 60 else 45)
if (applied != currentRate) {
param.setArg(0, applied)
}
mediaRecorderLog("requested=$currentRate applied=${param.arg<Int>(0)}")
}
OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param ->
@@ -477,7 +231,6 @@ class PerformanceMode : Feature("Performance Mode") {
if (updated != original) {
param.setArg(4, updated)
}
overScrollerLog("requested=$original applied=${param.arg<Int>(4)}")
}
}
@@ -490,46 +243,6 @@ class PerformanceMode : Feature("Performance Mode") {
}
}
fun applyActivityPerformanceTuning(activity: Activity) {
runCatching {
activity.window.setWindowAnimations(0)
}
}
onNextActivityCreate {
applyActivityPerformanceTuning(it)
}
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
val window = dialog.window ?: return@hook
runCatching {
window.setWindowAnimations(0)
if (dialog::class.java.name.contains("map", ignoreCase = true) || dialog::class.java.name.contains("snap", ignoreCase = true)) {
mapDialogLog("class=${dialog::class.java.name}")
}
}
}
runCatching {
findClass("com.mapbox.mapboxsdk.maps.MapView").hookConstructor(HookStage.AFTER) { param ->
val mapView = param.nullableThisObject<Any>() as? View ?: return@hookConstructor
mapView.overScrollMode = View.OVER_SCROLL_NEVER
mapViewLog("class=${mapView::class.java.name}")
}
}
runCatching {
findClass("com.mapbox.mapboxsdk.maps.renderer.MapRenderer").hook("setMaximumFps", HookStage.BEFORE) { param ->
val requested = param.arg<Int>(0)
val applied = requested.coerceAtLeast(120)
if (applied != requested) {
param.setArg(0, applied)
}
mapRendererFpsLog("requested=$requested applied=${param.arg<Int>(0)}")
}
}
runCatching {
val nativeMapViewClass = findClass("com.mapbox.mapboxsdk.maps.NativeMapView")
val transitionOptionsClass = findClass("com.mapbox.mapboxsdk.style.layers.TransitionOptions")
@@ -546,9 +259,9 @@ class PerformanceMode : Feature("Performance Mode") {
}
val nativeCancelTransitions = findNativeMapMethod("nativeCancelTransitions") { it.parameterCount == 0 }
val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType }
val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType }
val nativeSetPrefetchZoomDelta = findNativeMapMethod("nativeSetPrefetchZoomDelta") { it.parameterCount == 1 && it.parameterTypes[0] == Int::class.javaPrimitiveType }
val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionDuration = findNativeMapMethod("nativeSetTransitionDuration") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionOptions = findNativeMapMethod("nativeSetTransitionOptions") { it.parameterCount == 1 && it.parameterTypes[0].name == transitionOptionsClass.name }
@@ -556,15 +269,14 @@ class PerformanceMode : Feature("Performance Mode") {
val nativeMapView = param.thisObject<Any>()
runCatching {
nativeSetPrefetchTiles?.invoke(nativeMapView, true)
nativeSetPrefetchZoomDelta?.invoke(nativeMapView, snapMapPrefetchZoomDelta)
nativeSetPrefetchZoomDelta?.invoke(nativeMapView, 6)
nativeSetTransitionDelay?.invoke(nativeMapView, 0L)
nativeSetTransitionDuration?.invoke(nativeMapView, snapMapTransitionDurationMs)
nativeSetTransitionDuration?.invoke(nativeMapView, 0L)
nativeSetTransitionOptions?.invoke(
nativeMapView,
transitionOptionsCtor.newInstance(snapMapTransitionDurationMs, 0L, false)
transitionOptionsCtor.newInstance(0L, 0L, false)
)
nativeCancelTransitions?.invoke(nativeMapView)
mapTransitionLog("transitionMs=$snapMapTransitionDurationMs prefetchZoomDelta=$snapMapPrefetchZoomDelta placementTransitions=false")
}
}
@@ -574,12 +286,11 @@ class PerformanceMode : Feature("Performance Mode") {
method.parameterTypes.last() == Long::class.javaPrimitiveType
}?.hook(HookStage.BEFORE) { param ->
val original = param.arg<Long>(5)
val applied = clampPositiveDuration(original, snapMapCameraDurationMs)
val applied = original.coerceAtMost(16L)
if (applied != original) {
param.setArg(5, applied)
}
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
mapCameraAnimLog("requested=$original applied=${param.arg<Long>(5)}")
}
nativeMapViewClass.findRestrictedMethod { method ->
@@ -590,125 +301,57 @@ class PerformanceMode : Feature("Performance Mode") {
method.parameterTypes[2] == Long::class.javaPrimitiveType
}?.hook(HookStage.BEFORE) { param ->
val original = param.arg<Long>(2)
val applied = clampPositiveDuration(original, snapMapMoveDurationMs)
val applied = original.coerceAtMost(8L)
if (applied != original) {
param.setArg(2, applied)
}
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
mapMoveLog("requested=$original applied=${param.arg<Long>(2)}")
}
}.onFailure {
context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode")
}
runCatching {
findClass("com.snapchat.client.messaging.MessageWindowManager\$CppProxy").hook("initWindow", HookStage.BEFORE) { param ->
if (!isMaxProfile) return@hook
val conversationId = runCatching {
SnapUUID(param.arg(0)).toString()
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return@hook
val initParams = param.arg<Any>(1)
val conversationType = context.database.getConversationType(conversationId) ?: return@hook
val isGroup = conversationType == 1
val savedState = synchronized(messageWindowStates) {
messageWindowStates[conversationId]
?.takeIf { System.currentTimeMillis() - it.updatedAt <= MESSAGE_WINDOW_STATE_MAX_AGE_MS }
}
val enumConstants = initParams.getObjectField("mStartingType")?.javaClass?.enumConstants ?: return@hook
if (savedState != null) {
val restoredMaxSize = if (savedState.isGroup) {
savedState.currentSize.coerceAtLeast(220).coerceAtMost(520)
} else {
savedState.currentSize.coerceAtLeast(140).coerceAtMost(320)
}
val restoredForward = (savedState.currentSize + if (savedState.isGroup) 24 else 16).coerceAtMost(restoredMaxSize)
val restoredBack = if (savedState.isGroup) 180 else 120
initParams.setObjectField("mStartingType", enumConstants.firstOrNull { it.toString() == "MESSAGE" } ?: return@hook)
initParams.setObjectField("mStartingOrderKey", savedState.oldestOrderKey ?: savedState.newestOrderKey)
initParams.setObjectField("mMaxSize", restoredMaxSize)
initParams.setObjectField("mNumMessagesForward", restoredForward)
initParams.setObjectField("mNumMessagesBack", restoredBack)
val warmupAmount = if (savedState.isGroup) REOPEN_WARMUP_GROUP_MESSAGES else REOPEN_WARMUP_DM_MESSAGES
val oldestKey = savedState.oldestOrderKey
if (oldestKey != null) {
context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated(
conversationId = conversationId,
lastMessageId = oldestKey,
amount = warmupAmount,
onSuccess = {},
onError = {}
)
}
fun applyActivityPerformanceTuning(activity: Activity) {
runCatching {
activity.window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
val display = activity.display
val targetRefreshRate = display?.supportedModes?.maxByOrNull { it.refreshRate }?.refreshRate
?.coerceAtLeast(preferredRefreshRate) ?: preferredRefreshRate
activity.window.attributes = activity.window.attributes.apply {
this.preferredRefreshRate = targetRefreshRate
}
}
}.onFailure {
context.log.error("Failed to install saved message window restore hooks", it, "PerformanceMode")
}
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("MessageWindowManagerDelegate")?.hook("onWindowUpdated", HookStage.AFTER) { param ->
if (!isMaxProfile) return@hook
val conversationId = runCatching { SnapUUID(param.arg(0)).toString() }.getOrNull() ?: return@hook
val update = param.arg<Any>(2)
val pagination = update.getObjectField("mPagination") ?: return@hook
val currentSize = pagination.getObjectField("mCurrentSize") as? Int ?: return@hook
val oldestOrderKey = pagination.getObjectField("mOldestOrderKey") as? Long
val newestOrderKey = pagination.getObjectField("mNewestOrderKey") as? Long
val conversationType = context.database.getConversationType(conversationId) ?: 0
val isGroup = conversationType == 1
synchronized(messageWindowStates) {
messageWindowStates[conversationId] = MessageWindowState(
conversationId = conversationId,
currentSize = currentSize.coerceAtMost(if (isGroup) 420 else 260),
oldestOrderKey = oldestOrderKey,
newestOrderKey = newestOrderKey,
updatedAt = System.currentTimeMillis(),
isGroup = isGroup
)
while (messageWindowStates.size > MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS) {
val eldestKey = messageWindowStates.entries.minByOrNull { it.value.updatedAt }?.key ?: break
messageWindowStates.remove(eldestKey)
}
persistMessageWindowStates()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) {
runCatching {
activity.window.setSustainedPerformanceMode(true)
}
}
}
runCatching {
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.BEFORE) { param ->
if (!isMaxProfile) return@hook
val sql = param.argNullable<String>(1) ?: return@hook
if (!isChatFeedQuery(sql)) return@hook
if (chatFeedSnapshotServedThisProcess.get()) return@hook
val queryKey = buildChatFeedSnapshotQueryKey(sql)
readSnapshot(chatFeedSnapshotFile, queryKey)?.let { snapshot ->
param.setResult(snapshotToMatrixCursor(snapshot))
chatFeedSnapshotServedThisProcess.set(true)
}
}
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.AFTER) { param ->
if (!isMaxProfile) return@hook
val sql = param.argNullable<String>(1) ?: return@hook
if (!isChatFeedQuery(sql)) 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 queryKey = buildChatFeedSnapshotQueryKey(sql)
val snapshot = snapshotFromCursor(cursor) ?: return@hook
if (snapshot.rows.isEmpty()) return@hook
writeSnapshot(chatFeedSnapshotFile, queryKey, snapshot)
lastChatFeedSnapshotWrite.set(now)
}
}.onFailure {
context.log.error("Failed to install chat feed cache hooks", it, "PerformanceMode")
onNextActivityCreate {
applyActivityPerformanceTuning(it)
}
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
val window = dialog.window ?: return@hook
runCatching {
window.setWindowAnimations(0)
window.decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
window.attributes = window.attributes.apply {
flags = flags or WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
}
}
}
TextureView::class.java.hookConstructor(HookStage.AFTER) { param ->
val textureView = param.thisObject<TextureView>()
runCatching {
// Universal Guard: Only accelerate views owned by Snapchat.
// This prevents crashes in native hardware providers across all devices.
if (textureView.context.packageName != context.androidContext.packageName) return@runCatching
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
}
}
}
}

View File

@@ -0,0 +1,130 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.widget.TextView
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
class FakeFollowersCount : Feature("Fake Followers Count") {
override fun init() {
if (context.config.userInterface.spoofFollowersCount.globalState != true) return
val raw = context.config.userInterface.spoofFollowersCount.customFollowersCount.getNullable()?.trim()?.takeIf { it.isNotBlank() }
?: return
val digits = raw.replace(Regex("[^0-9]"), "")
if (digits.isEmpty()) return
val followerValue = try {
BigInteger(digits)
} catch (_: NumberFormatException) {
return
}
onNextActivityCreate {
TextView::class.java.hook("setText", HookStage.BEFORE) { param ->
val textView = param.thisObject() as? TextView ?: return@hook
if (textView.javaClass.name !in COMPOSER_SNAP_TEXT_VIEW) return@hook
val arg0 = param.argNullable<Any>(0) ?: return@hook
if (arg0 !is CharSequence) return@hook
val text = arg0.toString()
if (!FOLLOWERS_LINE.containsMatchIn(text)) return@hook
val display = formatFollowersDisplay(followerValue)
val replaced = FOLLOWERS_LINE.replace(text) { mr ->
"$display ${mr.groupValues[2]}"
}
param.setArg(0, replaced)
}
}
}
private fun formatFollowersDisplay(n: BigInteger): String {
val v = n.max(BigInteger.ZERO)
if (v < TEN_THOUSAND) {
return NumberFormat.getIntegerInstance(Locale.US).format(v.toLong())
}
val (scaled, suffix) = scaleToTier(v)
return formatMantissaMaxFourDigits(scaled) + suffix
}
private fun scaleToTier(v: BigInteger): Pair<BigDecimal, String> {
var i = SCALE_TIERS.indexOfLast { v >= it.floor }.coerceAtLeast(0)
var floor = SCALE_TIERS[i].floor
var suffix = SCALE_TIERS[i].suffix
var scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
while (scaled >= THOUSAND_BD && i + 1 < SCALE_TIERS.size) {
i++
floor = SCALE_TIERS[i].floor
suffix = SCALE_TIERS[i].suffix
scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX)
}
return Pair(scaled, suffix)
}
private fun formatMantissaMaxFourDigits(scaled: BigDecimal): String {
val x = scaled.abs().setScale(12, RoundingMode.HALF_UP)
if (x.compareTo(BigDecimal.ZERO) == 0) return "0"
if (x >= HUNDRED) {
val i = x.setScale(0, RoundingMode.HALF_UP)
var s = i.toPlainString()
if (digitCount(s) > 4) {
s = x.round(MathContext(4, RoundingMode.HALF_UP)).setScale(0, RoundingMode.HALF_UP).toPlainString()
}
return s
}
if (x >= TEN) {
var s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
if (digitCount(s) > 4) {
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
}
return s
}
var s = stripFrac(x.setScale(2, RoundingMode.HALF_UP))
if (digitCount(s) > 4) {
s = stripFrac(x.setScale(1, RoundingMode.HALF_UP))
}
if (digitCount(s) > 4) {
s = x.setScale(0, RoundingMode.HALF_UP).toPlainString()
}
return s
}
private fun digitCount(s: String) = s.count { it.isDigit() }
private fun stripFrac(d: BigDecimal): String {
var s = d.stripTrailingZeros().toPlainString()
if ('.' in s) {
s = s.trimEnd('0').trimEnd('.')
}
return s
}
private data class ScaleTier(val floor: BigInteger, val suffix: String)
companion object {
private val FOLLOWERS_LINE = Regex("^([\\d,]+)\\s+(Followers)\\b", RegexOption.IGNORE_CASE)
private val COMPOSER_SNAP_TEXT_VIEW = setOf(
"com.snap.valdi.views.ComposerSnapTextView",
"com.snap.composer.views.ComposerSnapTextView",
)
private val MATH_CTX = MathContext(24, RoundingMode.HALF_UP)
private val TEN_THOUSAND = BigInteger("10000")
private val THOUSAND_BD = BigDecimal("1000")
private val HUNDRED = BigDecimal("100")
private val TEN = BigDecimal("10")
private val SCALE_TIERS = listOf(
ScaleTier(BigInteger("1000"), "K"),
ScaleTier(BigInteger("1000000"), "M"),
ScaleTier(BigInteger("1000000000"), "B"),
ScaleTier(BigInteger("1000000000000"), "T"),
ScaleTier(BigInteger("1000000000000000"), "Q"),
ScaleTier(BigInteger("1000000000000000000"), "E"),
ScaleTier(BigInteger("1000000000000000000000"), "Z"),
ScaleTier(BigInteger("1000000000000000000000000"), "Y"),
)
}
}

View File

@@ -9,6 +9,8 @@ import android.text.TextPaint
import android.view.View
import android.view.ViewGroup
import android.graphics.Typeface
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -78,9 +80,10 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
val ffSdlPrimaryTextStartMargin = 6 * density
val feedEntryHeight = ffSdlAvatarSize + ffSdlAvatarMargin * 2 + (4 * density).toInt()
val separatorHeight = (density * 2).toInt()
val safetyGap = (6 * density).toInt()
val textPaint = TextPaint().apply {
textSize = secondaryTextSize
isAntiAlias = true
}
context.event.subscribe(BuildMessageEvent::class) { param ->
@@ -105,14 +108,15 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
}
fetchMessages(conversationId) {
var maxTextHeight = 0
val previewContainerHeight = messageCache[conversationId]?.sumOf { msg ->
val rect = Rect()
textPaint.getTextBounds(msg, 0, msg.length, rect)
rect.height().also {
if (it > maxTextHeight) maxTextHeight = it
}.plus(separatorHeight)
} ?: run {
val universalTextSize = 12 * density
val fontMetrics = textPaint.apply { textSize = universalTextSize }.fontMetrics
val lineHeight = (fontMetrics.descent - fontMetrics.ascent).toInt()
val spacing = (4 * density).toInt()
val messages = messageCache[conversationId]
val previewContainerHeight = if (messages.isNullOrEmpty()) 0 else (messages.size * (lineHeight + spacing))
if (previewContainerHeight == 0) {
ffItem.layoutParams = ffItem.layoutParams.apply {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
@@ -120,22 +124,23 @@ class FriendFeedMessagePreview : Feature("FriendFeedMessagePreview") {
}
ffItem.layoutParams = ffItem.layoutParams.apply {
height = feedEntryHeight + previewContainerHeight + separatorHeight
height = feedEntryHeight + (safetyGap).toInt() + previewContainerHeight
}
cachedLayouts[conversationId] = frameLayout
frameLayout.addForegroundDrawable("ffItem", ShapeDrawable(object: Shape() {
override fun draw(canvas: Canvas, paint: Paint) {
val offsetY = canvas.height.toFloat() - previewContainerHeight
paint.textSize = secondaryTextSize
paint.color = context.userInterface.colorPrimary
val startY = feedEntryHeight.toFloat() - (9 * density)
paint.textSize = universalTextSize
paint.color = Color(context.userInterface.colorPrimary).copy(alpha = 0.85f).toArgb()
paint.typeface = Typeface.DEFAULT
paint.isAntiAlias = true
messageCache[conversationId]?.forEachIndexed { index, messageString ->
messages?.forEachIndexed { index, messageString ->
canvas.drawText(messageString,
feedEntryHeight + ffSdlPrimaryTextStartMargin,
offsetY + index * maxTextHeight,
ffSdlAvatarSize + ffSdlAvatarMargin + (ffSdlPrimaryTextStartMargin * 3),
startY + (index + 1) * lineHeight + (index * spacing),
paint
)
}

View File

@@ -1,17 +1,31 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.view.View
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import java.util.ArrayList
import java.util.concurrent.ConcurrentHashMap
class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) {
@Volatile
private var cachedRuleIds: Set<String> = emptySet()
@Volatile
private var cachedRuleIdsAt = 0L
private val conversationTargetsCache = ConcurrentHashMap<String, Set<String>>()
private val hideDecisionCache = ConcurrentHashMap<String, Boolean>()
private var lastRuleIdsHash = 0
private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
from("mFeedEntryIdentifier") {
set("mConversationId", conversationIdInstance)
@@ -19,10 +33,59 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
set("mReason", "CLEAR_CONVERSATION")
}
private fun filterFriendFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) {
private fun getRuleIdsSnapshot(): Set<String> {
val now = System.currentTimeMillis()
if (now - cachedRuleIdsAt <= 1_000L) return cachedRuleIds
return context.bridgeClient.getRuleIds(ruleType).toSet().also {
cachedRuleIds = it
cachedRuleIdsAt = now
}
}
private fun resolveRuleTargets(conversationId: String): Set<String> {
return conversationTargetsCache.getOrPut(conversationId) {
val targets = linkedSetOf(conversationId)
context.database.getDMOtherParticipant(conversationId)?.let { targets.add(it) }
context.database.getFeedEntryByConversationId(conversationId)?.let { entry ->
entry.friendUserId?.let { targets.add(it) }
entry.participants?.forEach { targets.add(it) }
}
targets
}
}
private fun shouldHideConversation(
conversationId: String,
ruleIds: Set<String>,
ruleState: RuleState?
): Boolean {
if (ruleState == null) return false
// Industrial Cache Gating: Clear decisions if the master rule list changed
val currentHash = ruleIds.hashCode()
if (currentHash != lastRuleIdsHash) {
hideDecisionCache.clear()
lastRuleIdsHash = currentHash
}
return hideDecisionCache.getOrPut(conversationId) {
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch
}
}
private fun filterFriendFeed(
entries: ArrayList<Any>,
ruleIds: Set<String>,
ruleState: RuleState?,
deletedEntries: ArrayList<Any>? = null
) {
if (ruleState == null || entries.isEmpty()) return
entries.removeIf { feedEntry ->
val conversationIdInstance = feedEntry.getObjectField("mConversationId") ?: return@removeIf false
if (canUseRule(SnapUUID(conversationIdInstance).toString())) {
val conversationId = SnapUUID(conversationIdInstance).toString()
if (shouldHideConversation(conversationId, ruleIds, ruleState)) {
deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance)!!)
true
} else {
@@ -31,6 +94,20 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
}
}
private fun hideBoundChatFeedRow(view: View) {
var current: View? = view
repeat(4) {
val parent = current?.parent as? View
// Safety: Never hide the actual list container
if (parent?.javaClass?.name?.contains("RecyclerView") == true) {
current?.hideViewCompletely()
return
}
current?.hideViewCompletely()
current = parent
}
}
private fun hookCallbackMethod(
hookedCallbacks: MutableSet<String>,
callbackClassName: String,
@@ -51,6 +128,14 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
override fun init() {
if (!context.config.userInterface.hideFriendFeedEntry.get()) return
context.event.subscribe(BindViewEvent::class) { event ->
event.friendFeedItem { conversationId ->
if (shouldHideConversation(conversationId, getRuleIdsSnapshot(), getRuleState())) {
hideBoundChatFeedRow(event.view)
}
}
}
context.mappings.useMapper(CallbackMapper::class) {
classLoader = context.androidContext.classLoader
val hasFetchAndSyncCallback = callbacks.getAsMap()?.entries?.any {
@@ -69,13 +154,13 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
when {
callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchAndSyncFeedComplete") { param ->
val deletedConversations: ArrayList<Any> = param.arg(2)
filterFriendFeed(param.arg(0), deletedConversations)
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
val deletedConversations = param.argNullable<ArrayList<Any>>(2)
val ruleIds = getRuleIdsSnapshot()
val ruleState = getRuleState()
filterFriendFeed(entries, ruleIds, ruleState, deletedConversations)
if (deletedConversations.any {
val uuid = SnapUUID(it.getObjectField("mFeedEntryIdentifier")?.getObjectField("mConversationId")).toString()
context.database.getFeedEntryByConversationId(uuid) != null
}) {
if (deletedConversations?.isNotEmpty() == true) {
param.setArg(4, true)
}
}
@@ -83,34 +168,40 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onSyncFeedComplete") { param ->
filterFriendFeed(param.arg(0), param.argNullable(2))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState(), param.argNullable(2))
}
}
callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedComplete") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
}
callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFetchFeedEntriesComplete") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
}
callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onQueryFeedComplete") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
}
callbackName == "FeedManagerDelegate" -> {
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onFeedEntriesUpdated") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
hookCallbackMethod(hookedCallbacks, callbackClassName ?: return@forEach, "onInternalSyncFeed") { param ->
filterFriendFeed(param.arg(0))
val entries = param.argNullable<ArrayList<Any>>(0) ?: return@hookCallbackMethod
filterFriendFeed(entries, getRuleIdsSnapshot(), getRuleState())
}
}
}

View File

@@ -8,11 +8,18 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -25,6 +32,47 @@ import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.AppleLogo
import kotlin.random.Random
@Composable
private fun GradientIcon(
imageVector: ImageVector,
brush: Brush,
size: androidx.compose.ui.unit.Dp = 16.dp
) {
Image(
imageVector = imageVector,
contentDescription = null,
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier
.size(size)
.graphicsLayer(alpha = 0.99f)
.drawWithContent {
drawContent()
drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
}
)
}
@Composable
private fun GradientText(
text: String,
brush: Brush,
fontWeight: FontWeight,
fontSize: androidx.compose.ui.unit.TextUnit
) {
Text(
text = text,
color = Color.White,
fontWeight = fontWeight,
fontSize = fontSize,
modifier = Modifier
.graphicsLayer(alpha = 0.99f)
.drawWithContent {
drawContent()
drawRect(brush = brush, blendMode = BlendMode.SrcAtop)
}
)
}
class MessageIndicators : Feature("Message Indicators") {
override fun init() {
val messageIndicatorsConfig = context.config.userInterface.messageIndicators.getNullable() ?: return
@@ -35,28 +83,43 @@ class MessageIndicators : Feature("Message Indicators") {
val appleLogo = AppleLogo
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { _, _ ->
event.chatMessage { conversationId, _ ->
val view = event.view as? ViewGroup ?: return@subscribe
view.findViewWithTag<View>(messageInfoTag)?.let { view.removeView(it) }
val message = event.databaseMessage ?: return@chatMessage
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@chatMessage
if (message.senderId == context.database.myUserId && messageIndicatorsConfig.contains("skip_own_indicators")) return@chatMessage
val reader = ProtoReader(message.messageContent ?: return@chatMessage)
val isGroupConversation = (context.database.getConversationParticipants(conversationId)?.size ?: 0) > 2
if (isGroupConversation && messageIndicatorsConfig.contains("disable_indicators_in_groups")) return@chatMessage
createComposeView(event.view.context) {
val lockBrush = Brush.linearGradient(listOf(Color(0xFF4CD471), Color(0xFF00B8D9)))
val locationBrush = Brush.linearGradient(listOf(Color(0xFFFF4B5C), Color(0xFFFFB74D)))
val androidBrush = Brush.linearGradient(listOf(Color(0xFF3DDC84), Color(0xFFA5E635)))
val appleBrush = Brush.linearGradient(listOf(Color(0xFFFFFFFF), Color(0xFF94A3B8)))
val webBrush = Brush.linearGradient(listOf(Color(0xFF4FC3F7), Color(0xFF00E5FF)))
val directorBrush = Brush.linearGradient(listOf(Color(0xFFFFB74D), Color(0xFFFF5E3A)))
val ovfBrush = Brush.linearGradient(listOf(Color(0xFFFF5CA8), Color(0xFFA64CFF)))
val memoriesBrush = Brush.linearGradient(listOf(Color(0xFFFFE066), Color(0xFFFFB300)))
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(top = 4.dp, end = 1.dp),
.padding(top = 6.dp, end = 6.dp),
contentAlignment = Alignment.TopEnd
) {
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
// Strictly detect Private Fidelius Wrap (1-on-1 private snaps)
reader.containsPath(4, 4, 1, 1) ||
reader.containsPath(4, 4, 1, 1, 1) ||
reader.getByteArray(4, 3, 3) != null ||
reader.containsPath(3, 99, 3)
if (reader.containsPath(4, 4, 1, 1)
|| reader.containsPath(4, 4, 1, 1, 1)
|| reader.getByteArray(4, 3, 3) != null
|| reader.containsPath(3, 99, 3)) {
return@rememberAsyncMutableState true
}
if (reader.containsPath(4, 5, 1, 3, 1)) return@rememberAsyncMutableState true
reader.getVarInt(4, 5, 1, 3, 2, 9) in setOf(1L, 3L)
}
val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) {
if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null
@@ -75,52 +138,41 @@ class MessageIndicators : Feature("Message Indicators") {
(it.getVarInt(1) to it.getVarInt(2)) == (0L to 0L)
} == true || reader.getByteArray(4, 4, 11, 13, 4, 1, 2, 12, 27, 1) != null
}
val sentFromMemories by rememberAsyncMutableState(defaultValue = false) {
reader.getVarInt(4, 18) != null
|| reader.getString(4, 5, 1, 2)?.contains("/h/") == true
}
Row(
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
if (sentWithLocation && messageIndicatorsConfig.contains("location_indicator")) {
Image(
imageVector = Icons.Default.LocationOn,
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
GradientIcon(Icons.Default.LocationOn, locationBrush)
}
if (messageIndicatorsConfig.contains("platform_indicator")) {
Image(
imageVector = when {
sentFromWebApp -> Icons.Default.Laptop
sentFromIosDevice -> appleLogo
else -> Icons.Default.Android
},
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
val (platformIcon, platformBrush) = when {
sentFromWebApp -> Icons.Default.Laptop to webBrush
sentFromIosDevice -> appleLogo to appleBrush
else -> Icons.Default.Android to androidBrush
}
GradientIcon(platformIcon, platformBrush)
}
if (hasEncryption && messageIndicatorsConfig.contains("encryption_indicator")) {
Image(
imageVector = Icons.Default.Lock,
colorFilter = ColorFilter.tint(Color.Green),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
GradientIcon(Icons.Default.Lock, lockBrush)
}
if (sentUsingDirectorMode && messageIndicatorsConfig.contains("director_mode_indicator")) {
Image(
imageVector = Icons.Default.Edit,
colorFilter = ColorFilter.tint(Color.Red),
contentDescription = null,
modifier = Modifier.size(15.dp)
)
GradientIcon(Icons.Default.Edit, directorBrush)
}
if (sentFromMemories && messageIndicatorsConfig.contains("memories_indicator")) {
GradientIcon(Icons.Default.HistoryEdu, memoriesBrush)
}
if (sentUsingOvfEditor && messageIndicatorsConfig.contains("ovf_editor_indicator")) {
Text(
GradientText(
text = "OVF",
color = Color.Red,
brush = ovfBrush,
fontWeight = FontWeight.ExtraBold,
fontSize = 10.sp,
fontSize = 11.sp
)
}
}

View File

@@ -7,47 +7,112 @@ import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import java.util.Collections
class PinConversations : MessagingRuleFeature("PinConversations", MessagingRuleType.PIN_CONVERSATION) {
companion object {
// 3-year offset for persistent local conversation sorting
private const val PIN_OFFSET = 100000000000L
}
private fun forcePinsInFeed(entries: ArrayList<Any>) {
val now = System.currentTimeMillis()
// Capture stable timestamp once to prevent jitter during the sweep
val stableTimestamp = now + PIN_OFFSET
entries.forEach { entry ->
val conversationIdObject = entry.getObjectFieldOrNull("mConversationId") ?: return@forEach
runCatching {
val conversationUUID = SnapUUID(conversationIdObject)
if (getState(conversationUUID.toString())) {
// Apply identical timestamp lead to all pinned items
entry.setObjectField("mPinnedTimestampMs", stableTimestamp)
} else {
// Reset timestamp if it's currently a "Future" timestamp but shouldn't be pinned
val currentTs = entry.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L
if (currentTs > now + (PIN_OFFSET / 2)) {
entry.setObjectField("mPinnedTimestampMs", now)
}
}
}
}
// Manual sort to ensure stable UI transition and prevent list jumping
runCatching {
Collections.sort(entries) { a, b ->
val tsA = a.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L
val tsB = b.getObjectFieldOrNull("mPinnedTimestampMs") as? Long ?: 0L
tsB.compareTo(tsA)
}
}
}
override fun init() {
if (!context.config.messaging.unlimitedConversationPinning.get()) return
// Intercept native pinning requests and bypass server-side limits
context.classCache.feedManager.hook("setPinnedConversationStatus", HookStage.BEFORE) { param ->
val conversationUUID = SnapUUID(param.arg(0))
val isPinned = param.arg<Any>(1).toString() == "PINNED"
setState(conversationUUID.toString(), isPinned)
// Callback forcing to suppress "Can't pin conversation" errors for both PIN and UNPIN
val callback = param.arg<Any>(2)
mutableSetOf<() -> Unit>().apply {
addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onSuccess", HookStage.BEFORE) {
addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback, "onSuccess", HookStage.BEFORE) {
forEach { it() }
})
addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback,"onError", HookStage.BEFORE) { methodParam ->
addAll(Hooker.ephemeralHookObjectMethod(callback::class.java, callback, "onError", HookStage.BEFORE) { methodParam ->
methodParam.setResult(null)
// Manually trigger success to bypass server-side limit rejections
callback::class.java.getDeclaredMethod("onSuccess").invoke(callback)
})
}
}
context.classCache.conversation.hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val conversationUUID = SnapUUID(instance.getObjectField("mConversationId"))
if (getState(conversationUUID.toString())) {
instance.setObjectField("mPinnedTimestampMs", 1L)
// Active feed sweep to ensure pinned conversations remain at the top
context.mappings.useMapper(CallbackMapper::class) {
val callbackMap = callbacks.getAsMap().orEmpty()
callbackMap.entries.forEach { (_, className) ->
val clazz = runCatching { findClass(className!!) }.getOrNull() ?: return@forEach
clazz.methods.forEach { method ->
if (method.name.startsWith("on") && method.name.endsWith("Complete") && method.parameterTypes.any { it == ArrayList::class.java }) {
clazz.hook(method.name, HookStage.BEFORE) { param ->
(param.args().firstOrNull { it is ArrayList<*> } as? ArrayList<Any>)?.let { forcePinsInFeed(it) }
}
}
}
}
}
// Apply pinning lead to newly created conversation objects
context.classCache.conversation.hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val conversationIdObject = instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor
runCatching {
val conversationUUID = SnapUUID(conversationIdObject)
if (getState(conversationUUID.toString())) {
instance.setObjectField("mPinnedTimestampMs", System.currentTimeMillis() + PIN_OFFSET)
}
}
}
// Apply pinning lead to newly created feed entry objects
context.classCache.feedEntry.hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val conversationUUID = SnapUUID(instance.getObjectField("mConversationId") ?: return@hookConstructor)
val isPinned = getState(conversationUUID.toString())
if (isPinned) {
instance.setObjectField("mPinnedTimestampMs", 1L)
val conversationIdObject = instance.getObjectFieldOrNull("mConversationId") ?: return@hookConstructor
runCatching {
val conversationUUID = SnapUUID(conversationIdObject)
if (getState(conversationUUID.toString())) {
instance.setObjectField("mPinnedTimestampMs", System.currentTimeMillis() + PIN_OFFSET)
}
}
}
}
override fun getRuleState() = RuleState.WHITELIST
}
}

View File

@@ -137,6 +137,8 @@ fun View.onAttachChange(onAttach: (View.OnAttachStateChangeListener) -> Unit = {
fun View.hideViewCompletely() {
fun hide() {
if (visibility == View.GONE && layoutParams?.width == 0 && layoutParams?.height == 0) return
isEnabled = false
visibility = View.GONE
setWillNotDraw(true)

View File

@@ -12,11 +12,12 @@ import java.net.SocketException
import java.util.Locale
import java.util.StringTokenizer
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.random.Random
class HttpServer(
private val timeout: Int = 10000
private val timeout: Int = 15000 // Optimized: 15s Middle Ground
) {
private fun newRandomPort() = Random.nextInt(10000, 65535)
@@ -53,18 +54,24 @@ class HttpServer(
AbstractLogger.directDebug("Starting http server on port $port")
for (i in 0..5) {
try {
serverSocket = ServerSocket(port)
serverSocket = ServerSocket(port).apply {
soTimeout = timeout + 5000
}
break
} catch (e: Throwable) {
AbstractLogger.directError("failed to start http server on port $port", e)
port = newRandomPort()
}
}
continuation.resumeWith(Result.success(if (serverSocket == null) null.also {
if (serverSocket == null) {
continuation.resume(null)
return@launch
} else this@HttpServer))
}
continuation.resume(this@HttpServer)
while (!serverSocket!!.isClosed) {
while (isActive && serverSocket?.isClosed == false) {
try {
val socket = serverSocket!!.accept()
timeoutJob?.cancel()
@@ -77,14 +84,12 @@ class HttpServer(
socketJob?.cancel()
socket.close()
serverSocket?.close()
}.onFailure {
AbstractLogger.directError("failed to close socket", it)
}
}
}
} catch (e: SocketException) {
AbstractLogger.directDebug("http server timed out")
break;
AbstractLogger.directDebug("http server timed out or closed")
break
} catch (e: Throwable) {
AbstractLogger.directError("failed to handle request", e)
}
@@ -96,8 +101,11 @@ class HttpServer(
}
fun close() {
runCatching {
serverSocket?.close()
coroutineScope.launch {
runCatching {
serverSocket?.close()
socketJob?.cancel()
}
}
}
@@ -133,19 +141,21 @@ class HttpServer(
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val outputStream = socket.getOutputStream()
val writer = PrintWriter(outputStream)
val line = reader.readLine() ?: return
val line = runCatching { reader.readLine() }.getOrNull() ?: return
fun close() {
runCatching {
reader.close()
writer.close()
outputStream.close()
socket.close()
}.onFailure {
AbstractLogger.directError("failed to close socket", it)
}
}
val parse = StringTokenizer(line)
if (!parse.hasMoreTokens()) { close(); return }
val method = parse.nextToken().uppercase(Locale.getDefault())
if (!parse.hasMoreTokens()) { close(); return }
var fileRequested = parse.nextToken().lowercase(Locale.getDefault())
AbstractLogger.directDebug("[http-server:${port}] $method $fileRequested")

View File

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

View File

@@ -115,8 +115,8 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://api.xposed.info/") }
maven { url = uri("https://jitpack.io") }
maven { url = uri("https://api.xposed.info/") }
}
}