Merge pull request #143 from Kaladin
Features, Stability and Bug fixes.
This commit is contained in:
@@ -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? {
|
||||
|
||||
@@ -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++))
|
||||
|
||||
@@ -168,7 +168,7 @@ class FFMpegProcessor(
|
||||
}
|
||||
Action.MERGE_OVERLAY -> {
|
||||
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()) {
|
||||
@@ -211,7 +211,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 +228,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 +264,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -139,10 +139,10 @@ fun AppDatabase.replaceMessagingData(
|
||||
database.endTransaction()
|
||||
}
|
||||
|
||||
// Notify with the full updated list from the DB
|
||||
// Notify all observers with the updated data from the database
|
||||
val allFriends = getFriends(descOrder = true)
|
||||
val allGroups = getGroups()
|
||||
receiveMessagingDataCallback(allFriends, allGroups)
|
||||
messagingDataFlow.tryEmit(allFriends to allGroups)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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,10 @@ 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 ruleIdsSet by remember { derivedStateOf { currentRuleIds.toSet() } }
|
||||
|
||||
fun setRuleState(newState: RuleState?) {
|
||||
ruleState = newState
|
||||
@@ -163,12 +161,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 (!currentRuleIds.contains(friend.userId)) currentRuleIds.add(friend.userId)
|
||||
} else {
|
||||
currentRuleIds.remove(friend.userId)
|
||||
}
|
||||
@@ -176,16 +174,16 @@ class ManageRuleFeature : Routes.Route() {
|
||||
onGroupState = { group, state ->
|
||||
context.database.setRule(group.conversationId, currentRuleType.key, state)
|
||||
if (state) {
|
||||
currentRuleIds.add(group.conversationId)
|
||||
if (!currentRuleIds.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
|
||||
} else {
|
||||
currentRuleIds.remove(group.conversationId)
|
||||
}
|
||||
},
|
||||
getFriendState = { friend ->
|
||||
currentRuleIds.contains(friend.userId)
|
||||
ruleIdsSet.contains(friend.userId)
|
||||
},
|
||||
getGroupState = { group ->
|
||||
currentRuleIds.contains(group.conversationId)
|
||||
ruleIdsSet.contains(group.conversationId)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -230,59 +228,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 +292,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,26 +225,34 @@ class AddFriendDialog(
|
||||
friends: List<MessagingFriendInfo>,
|
||||
groups: List<MessagingGroupInfo>
|
||||
) {
|
||||
cachedFriends = context.sortSocialFriends(friends, pinnedIds = pinnedIds)
|
||||
cachedGroups = groups.run {
|
||||
if (pinnedIds != null) {
|
||||
sortedBy { -pinnedIds.indexOf(it.conversationId) }
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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),
|
||||
@@ -250,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +281,6 @@ class AddFriendDialog(
|
||||
onDispose {
|
||||
timeoutJob?.cancel()
|
||||
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
|
||||
context.database.receiveMessagingDataCallback = { _, _ -> }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,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 ->
|
||||
@@ -350,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(
|
||||
@@ -359,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,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(
|
||||
@@ -431,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -2,21 +2,29 @@ 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> {
|
||||
if (config.root.userInterface.sortSocialTabByStreakLength.get()) {
|
||||
return friends.sortedWith(
|
||||
compareByDescending<MessagingFriendInfo> { (it.streaks?.length ?: 0) > 0 }
|
||||
.thenByDescending { it.streaks?.length ?: 0 }
|
||||
)
|
||||
}
|
||||
val whitelistedIds = pinnedIds?.toSet() ?: database.getFriends().map { it.userId }.toSet()
|
||||
val sortByStreakLength = config.root.userInterface.sortSocialTabByStreakLength.get()
|
||||
|
||||
return if (pinnedIds != null) {
|
||||
friends.sortedBy { -pinnedIds.indexOf(it.userId) }
|
||||
} else {
|
||||
friends
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,30 @@ 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 { (friends, groups) ->
|
||||
withContext(Dispatchers.IO) {
|
||||
val sortedFriends = context.sortSocialFriends(friends)
|
||||
withContext(Dispatchers.Main) {
|
||||
friendList = sortedFriends
|
||||
groupList = groups
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,11 +143,6 @@ class SocialRootSection : Routes.Route() {
|
||||
addFriendDialog?.Content {
|
||||
addFriendDialog = null
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
updateScopeLists()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FloatingActionButton(
|
||||
|
||||
@@ -245,7 +245,6 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
|
||||
downloadState: UpdateDownloader.DownloadState,
|
||||
downloadProgress: Float,
|
||||
onUpdateAction: () -> Unit,
|
||||
channelLabel: String,
|
||||
isPurrAuraActive: Boolean,
|
||||
onAboutClick: () -> Unit,
|
||||
avenirNext: FontFamily,
|
||||
@@ -302,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))
|
||||
}
|
||||
@@ -463,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()
|
||||
@@ -521,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")
|
||||
@@ -559,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")
|
||||
@@ -701,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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,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() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ 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"])
|
||||
}
|
||||
@@ -58,27 +61,11 @@ 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 sortByStreakLength by produceState(initialValue = context.config.root.userInterface.sortSocialTabByStreakLength.get()) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.userInterface.sortSocialTabByStreakLength.get()
|
||||
}
|
||||
}
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
val filteredFriends = remember(friendList, normalizedQuery, sortByStreakLength) {
|
||||
val matchingFriends = if (normalizedQuery.isBlank()) {
|
||||
|
||||
// Filter logic based on the parent's synchronized data lists
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
friendList
|
||||
} else {
|
||||
friendList.filter {
|
||||
@@ -86,8 +73,6 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
|
||||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
context.sortSocialFriends(matchingFriends)
|
||||
}
|
||||
val filteredGroups = remember(groupList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
|
||||
@@ -210,7 +210,6 @@ object LegacyTheme : ThemeContract {
|
||||
downloadState: UpdateDownloader.DownloadState,
|
||||
downloadProgress: Float,
|
||||
onUpdateAction: () -> Unit,
|
||||
channelLabel: String,
|
||||
isPurrAuraActive: Boolean,
|
||||
onWebsiteClick: () -> Unit,
|
||||
onTelegramClick: () -> Unit,
|
||||
@@ -243,7 +242,7 @@ object LegacyTheme : ThemeContract {
|
||||
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))
|
||||
}
|
||||
@@ -357,19 +356,18 @@ object LegacyTheme : ThemeContract {
|
||||
}
|
||||
hasInitializedQuickTiles -> storedTiles
|
||||
else -> {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(QUICK_TILES_INITIALIZED_PREF, true).apply()
|
||||
context.coroutineScope.launch(Dispatchers.IO) {
|
||||
context.database.setQuickTiles(allQuickTileNames)
|
||||
prefs.edit().putBoolean(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)) {
|
||||
val channel = if (updateChannel == "prerelease") Channel.PRERELEASE else Channel.STABLE
|
||||
Updater.getLatestRelease(channel)
|
||||
val latestUpdate by rememberAsyncMutableState(defaultValue = null) {
|
||||
Updater.getLatestRelease(Channel.STABLE)
|
||||
}
|
||||
val changelogUrl = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
|
||||
val changelogUrl = changelogStableUrl
|
||||
val downloadState by UpdateDownloader.downloadState.collectAsState()
|
||||
val downloadProgress by UpdateDownloader.downloadProgress.collectAsState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
@@ -528,7 +526,6 @@ object LegacyTheme : ThemeContract {
|
||||
downloadState = downloadState,
|
||||
downloadProgress = downloadProgress,
|
||||
onUpdateAction = onUpdateButtonClick,
|
||||
channelLabel = channelLabel,
|
||||
isPurrAuraActive = isPurrAuraActive,
|
||||
onWebsiteClick = { context.androidContext.openLink("https://purrfectsnap.vercel.app/", context.translation["toast_open_link_failed"]) },
|
||||
onTelegramClick = { context.androidContext.openLink("https://t.me/purrfectsnap_official", context.translation["toast_open_link_failed"]) },
|
||||
@@ -873,24 +870,12 @@ object LegacyTheme : ThemeContract {
|
||||
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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1925,62 +1910,69 @@ object LegacyTheme : ThemeContract {
|
||||
fun LogFilterDialog() {
|
||||
androidx.compose.ui.window.Dialog(onDismissRequest = { showFilterDialog = false }) {
|
||||
me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme {
|
||||
me.eternal.purrfectsnap.core.ui.PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomeLogs.LogCategory.entries.forEach { category ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable {
|
||||
// Solo Focus Logic: Tap the name to filter only this category
|
||||
enabledCategories.keys.forEach { enabledCategories[it] = false }
|
||||
enabledCategories[category] = true
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
me.eternal.purrfectsnap.core.ui.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
|
||||
isRefreshing = true
|
||||
refreshLogs()
|
||||
},
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = PurrfectPalette.glowPrimary,
|
||||
uncheckedColor = Color.White.copy(alpha = 0.4f),
|
||||
checkmarkColor = Color.White
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = translation[category.translationKey] ?: category.name,
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = { showFilterDialog = false },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary)
|
||||
) {
|
||||
Text(translation["filter_logs_done_button"] ?: "Done")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showFilterDialog) {
|
||||
LogFilterDialog()
|
||||
}
|
||||
@@ -2060,6 +2052,9 @@ object LegacyTheme : ThemeContract {
|
||||
}
|
||||
|
||||
@Composable override fun SocialRootSection.SocialScreen(nav: NavBackStackEntry) {
|
||||
// Controller handles data loading and synchronization
|
||||
SocialDataController()
|
||||
|
||||
val titles = remember {
|
||||
listOf(translation["friends_tab"], translation["groups_tab"])
|
||||
}
|
||||
@@ -2068,27 +2063,11 @@ object LegacyTheme : ThemeContract {
|
||||
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 sortByStreakLength by produceState(initialValue = context.config.root.userInterface.sortSocialTabByStreakLength.get()) {
|
||||
while (true) {
|
||||
delay(300)
|
||||
value = context.config.root.userInterface.sortSocialTabByStreakLength.get()
|
||||
}
|
||||
}
|
||||
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
|
||||
val filteredFriends = remember(friendList, normalizedQuery, sortByStreakLength) {
|
||||
val matchingFriends = if (normalizedQuery.isBlank()) {
|
||||
|
||||
// Filter logic based on the parent's synchronized data lists
|
||||
val filteredFriends = remember(friendList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
friendList
|
||||
} else {
|
||||
friendList.filter {
|
||||
@@ -2096,8 +2075,6 @@ object LegacyTheme : ThemeContract {
|
||||
it.displayName?.contains(normalizedQuery, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
context.sortSocialFriends(matchingFriends)
|
||||
}
|
||||
val filteredGroups = remember(groupList, normalizedQuery) {
|
||||
if (normalizedQuery.isBlank()) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -2338,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",
|
||||
@@ -2665,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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -377,17 +377,17 @@
|
||||
"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",
|
||||
@@ -1545,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"
|
||||
@@ -1766,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",
|
||||
@@ -1778,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."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1870,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"
|
||||
@@ -2155,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"
|
||||
},
|
||||
@@ -2725,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": {
|
||||
@@ -2907,6 +2923,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",
|
||||
@@ -3267,11 +3285,11 @@
|
||||
"encryption_indicator": "Adds a \ud83d\udd12 icon next to messages that have been sent only to you",
|
||||
"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",
|
||||
"live_camera_indicator": "Adds a \ud83d\dcf7 icon when the snap was captured with the in-app camera (live)",
|
||||
"external_media_indicator": "Adds a \ud83d\uddbc\ufe0f icon for camera roll / gallery media and snaps sent from the gallery",
|
||||
"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",
|
||||
"memories_indicator": "Adds an open-book style icon (\ud83d\udcd6) when a snap was re-sent from Memories (not live capture)"
|
||||
"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",
|
||||
@@ -3852,7 +3870,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",
|
||||
@@ -3869,7 +3892,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",
|
||||
@@ -3886,9 +3908,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",
|
||||
@@ -3903,12 +3925,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}",
|
||||
@@ -4036,7 +4054,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",
|
||||
@@ -4121,10 +4139,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",
|
||||
@@ -4331,8 +4349,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",
|
||||
@@ -4477,4 +4493,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."
|
||||
}
|
||||
}
|
||||
@@ -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": "संदेश भेजते समय वार्तालाप को पढ़े गए के रूप में चिह्नित करें",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -57,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", "live_camera_indicator", "external_media_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -348,22 +348,24 @@ class BridgeClient(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -373,19 +375,18 @@ 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 ->
|
||||
repeat(totalParts) { index ->
|
||||
connectedService.passGroupsAndFriends(
|
||||
groupChunks.getOrElse(index) { emptyList() },
|
||||
friendChunks.getOrElse(index) { emptyList() }
|
||||
groupParts.getOrElse(index) { emptyList() },
|
||||
friendParts.getOrElse(index) { emptyList() },
|
||||
index,
|
||||
totalParts
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -155,62 +155,78 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
callback = object: DownloadCallback.Stub() {
|
||||
override fun onSuccess(outputFile: String) {
|
||||
var finalOutputFile = outputFile
|
||||
runCatching {
|
||||
val file = java.io.File(outputFile)
|
||||
if (file.exists()) {
|
||||
val header = file.inputStream().use { input ->
|
||||
val buffer = ByteArray(16)
|
||||
input.read(buffer)
|
||||
buffer
|
||||
}
|
||||
modCtx.coroutineScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
// settle delay to ensure disk flush
|
||||
delay(120L)
|
||||
val file = java.io.File(outputFile)
|
||||
if (file.exists()) {
|
||||
val header = file.inputStream().use { input ->
|
||||
val buffer = ByteArray(16)
|
||||
input.read(buffer)
|
||||
buffer
|
||||
}
|
||||
|
||||
val fileType = FileType.fromByteArray(header)
|
||||
if (fileType.isVideo && !outputFile.endsWith(".mp4", ignoreCase = true)) {
|
||||
val newPath = outputFile.removeSuffix(".dat").removeSuffix(".tmp") + ".mp4"
|
||||
val newFile = java.io.File(newPath)
|
||||
if (file.renameTo(newFile)) {
|
||||
finalOutputFile = newPath
|
||||
} else {
|
||||
file.copyTo(newFile, overwrite = true)
|
||||
file.delete()
|
||||
finalOutputFile = newPath
|
||||
val fileType = FileType.fromByteArray(header)
|
||||
val expectedExt = fileType.fileExtension
|
||||
|
||||
if (fileType != FileType.UNKNOWN && expectedExt != null &&
|
||||
!outputFile.endsWith(".$expectedExt", ignoreCase = true)) {
|
||||
val base = outputFile.substringBeforeLast('.').takeIf { '.' in outputFile } ?: outputFile
|
||||
val newPath = "$base.$expectedExt"
|
||||
val newFile = java.io.File(newPath)
|
||||
if (file.renameTo(newFile)) {
|
||||
finalOutputFile = newPath
|
||||
} else {
|
||||
file.copyTo(newFile, overwrite = true)
|
||||
file.delete()
|
||||
finalOutputFile = newPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { logError("Post-Processing Failed for $outputFile", it) }
|
||||
|
||||
if (isBatch) {
|
||||
batchSuccessCount.incrementAndGet()
|
||||
if (downloadLogging.contains("success")) {
|
||||
modCtx.runOnUiThread {
|
||||
modCtx.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Outlined.DownloadDone,
|
||||
text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()),
|
||||
durationMs = 1300
|
||||
)
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}.onFailure { logError("Post-Processing Logic Failed for $outputFile", it) }
|
||||
|
||||
if (isBatch) {
|
||||
batchSuccessCount.incrementAndGet()
|
||||
if (downloadLogging.contains("success")) {
|
||||
modCtx.inAppOverlay.showStatusToast(
|
||||
icon = Icons.Outlined.DownloadDone,
|
||||
text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()),
|
||||
durationMs = 1300
|
||||
)
|
||||
val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name)
|
||||
modCtx.runOnUiThread {
|
||||
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
|
||||
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (downloadLogging.contains("success")) {
|
||||
val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name)
|
||||
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
|
||||
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProgress(message: String) {
|
||||
if (isBatch || !downloadLogging.contains("progress")) return
|
||||
val toastText = message.ifBlank { translations["download_started_toast"] ?: "Started" }
|
||||
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
|
||||
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300)
|
||||
modCtx.runOnUiThread {
|
||||
if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText)
|
||||
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(message: String, throwable: String?) {
|
||||
if (!downloadLogging.contains("failure")) return
|
||||
val errorText = translations[if (message == "Failed to download") "failed_generic_toast" else message] ?: message
|
||||
if (isBatch) { batchFailureCount.incrementAndGet(); return }
|
||||
if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText)
|
||||
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300)
|
||||
modCtx.runOnUiThread {
|
||||
if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText)
|
||||
modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -331,11 +347,28 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
val totalCount = paramMap.getStorySnapTotal()
|
||||
modCtx.runOnUiThread {
|
||||
fun tryJump(retryCount: Int = 0) {
|
||||
val maxRetries = 4
|
||||
val delayMs = when {
|
||||
retryCount == 0 -> 180L
|
||||
retryCount == 1 -> 280L
|
||||
retryCount == 2 -> 400L
|
||||
else -> 550L
|
||||
}
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
if (synchronized(batchLock) { pendingBatchDownloadIndices } == null) return@postDelayed
|
||||
|
||||
val jumped = runCatching { modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) }.getOrNull() == true
|
||||
if (!jumped && retryCount < 1) tryJump(retryCount + 1)
|
||||
else if (!jumped) { synchronized(batchLock) { pendingBatchDownloadIndices = null }; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") }
|
||||
}, if (retryCount == 0) 120L else 220L)
|
||||
|
||||
when {
|
||||
jumped -> {}
|
||||
retryCount < maxRetries -> tryJump(retryCount + 1)
|
||||
else -> {
|
||||
synchronized(batchLock) { pendingBatchDownloadIndices = null }
|
||||
modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed")
|
||||
}
|
||||
}
|
||||
}, delayMs)
|
||||
}
|
||||
tryJump()
|
||||
}
|
||||
@@ -464,19 +497,38 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
|
||||
downloadOperaMedia(provideDownloadManagerClient("${msg.clientConversationId}${msg.senderId}${msg.serverMessageId}", author.usernameForSorting!!, msg.creationTimestamp, MediaDownloadSource.CHAT_MEDIA, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap)
|
||||
return
|
||||
}
|
||||
paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let {
|
||||
val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("userId=")?.substringBefore(",")
|
||||
val author = modCtx.database.getFriendInfo(storyUserId ?: modCtx.database.myUserId) ?: return@let
|
||||
paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let { playlistGroup ->
|
||||
val playlistGroupString = playlistGroup.toString()
|
||||
val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let {
|
||||
if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null
|
||||
} ?: if (playlistGroupString.contains("storyUserId=")) {
|
||||
playlistGroupString.substringAfter("storyUserId=").substringBefore(",")
|
||||
} else {
|
||||
val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" }?.invoke(playlistGroup)?.toString()?.split(":")?.getOrNull(2) ?: return@let
|
||||
val conversationMessage = modCtx.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let
|
||||
val conversationParticipants = modCtx.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let
|
||||
conversationParticipants.firstOrNull { it != conversationMessage.senderId }
|
||||
}
|
||||
|
||||
val author = modCtx.database.getFriendInfo(if (storyUserId == null || storyUserId == "null") modCtx.database.myUserId else storyUserId) ?: return@let
|
||||
if (!forceDownload && ((modCtx.config.downloader.preventSelfAutoDownload.get() && author.userId == modCtx.database.myUserId) || !canUseRule(author.userId!!))) return@let
|
||||
downloadOperaMedia(provideDownloadManagerClient(paramMap["MEDIA_ID"].toString(), author.usernameForSorting!!, null, MediaDownloadSource.STORY, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap)
|
||||
return
|
||||
}
|
||||
val snapSource = paramMap["SNAP_SOURCE"].toString()
|
||||
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || shouldAutoDownload("spotlight"))) {
|
||||
downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), paramMap["CREATOR_DISPLAY_NAME"].toString(), null, MediaDownloadSource.SPOTLIGHT, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap); return
|
||||
downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), (paramMap["CREATOR_DISPLAY_NAME"]?.toString() ?: "unknown").sanitizeForPath(), null, MediaDownloadSource.SPOTLIGHT, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap); return
|
||||
}
|
||||
if (!forceDownload && !shouldAutoDownload("public_stories")) return
|
||||
val author = (paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername } ?: paramMap["USERNAME"]?.toString()?.substringAfter("value=")?.substringBefore(")") ?: "unknown").sanitizeForPath()
|
||||
val rawAuthor = (
|
||||
paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername }
|
||||
?: paramMap["USERNAME"]?.toString()?.takeIf { it.contains("value=") }?.substringAfter("value=")?.substringBefore(")")?.substringBefore(",")
|
||||
?: paramMap["CONTEXT_USER_IDENTITY"]?.toString()?.takeIf { it.contains("username=") }?.substringAfter("username=")?.substringBefore(",")
|
||||
?: paramMap["USER_DISPLAY_NAME"]?.toString()?.takeIf { it.isNotEmpty() }
|
||||
?: paramMap["TIME_STAMP"]?.toString()
|
||||
?: "unknown"
|
||||
)
|
||||
val author = rawAuthor.sanitizeForPath().replace(":", "_").replace("/", "_").replace("\\", "_").replace("?", "_").replace("*", "_").replace("\"", "_").replace("<", "_").replace(">", "_").replace("|", "_")
|
||||
downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), author, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == false) 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")
|
||||
@@ -409,12 +495,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
|
||||
}
|
||||
|
||||
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 +511,58 @@ 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.")
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
|
||||
private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID)
|
||||
|
||||
fun getInterface(): AutoOpenInterface {
|
||||
@@ -450,7 +575,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())
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -64,11 +70,25 @@ 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
|
||||
|
||||
// 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 fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String, snapDurationMs: Int?) {
|
||||
queuedOriginalItemRepeatCount = repeatCount
|
||||
queuedOriginalItemRepeatOverrideType = overrideType
|
||||
@@ -83,7 +103,7 @@ class SendOverride : Feature("Send Override") {
|
||||
}
|
||||
|
||||
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
|
||||
if (queuedOriginalItemRepeatCount <= 0) {
|
||||
if (isStopped.get() || queuedOriginalItemRepeatCount <= 0) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
return false
|
||||
}
|
||||
@@ -116,6 +136,85 @@ class SendOverride : Feature("Send Override") {
|
||||
private val backgroundHookLock = Any()
|
||||
private var backgroundHookRefs = 0
|
||||
private var backgroundHooks: List<Hooker.HookHandle>? = null
|
||||
private val engineActive = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
||||
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 updateContinuousSendNotification() {
|
||||
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(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) // The Industrial Loop icon
|
||||
.setColor(0xFF3498DB.toInt()) // Industrial Purple/Blue tint
|
||||
.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(ACTION_PAUSE_RESUME)).build())
|
||||
builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP)).build())
|
||||
|
||||
notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
private fun showCompletionNotification(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(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 acquireScheduledSendBackground(): () -> Unit {
|
||||
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
|
||||
var enableFailed = false
|
||||
@@ -196,7 +295,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()
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
isStopped.set(true)
|
||||
if (isPaused.get()) {
|
||||
isPaused.set(false)
|
||||
}
|
||||
updateContinuousSendNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -806,6 +933,11 @@ class SendOverride : Feature("Send Override") {
|
||||
if (repeatCount <= 0) return false
|
||||
|
||||
fun sendIteration(index: Int) {
|
||||
if (isStopped.get()) {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
updateContinuousSendNotification()
|
||||
return
|
||||
}
|
||||
val callback = if (index == repeatCount - 1) {
|
||||
originalCallback
|
||||
} else {
|
||||
@@ -1429,6 +1561,11 @@ class SendOverride : Feature("Send Override") {
|
||||
invokeOriginalAndRestoreResult(event)
|
||||
}
|
||||
} else if (MediaFilePicker.hasReusableOriginalItem()) {
|
||||
totalRepeatCount = repeatCount
|
||||
processedRepeatCount = 1
|
||||
currentRecipientName = recipientNameForTask
|
||||
updateContinuousSendNotification()
|
||||
|
||||
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType, selectedSnapDurationMs)
|
||||
attachQueuedRepeatCallbacks(event)
|
||||
if (sendMedia(finalSelectedType, selectedSnapDurationMs)) {
|
||||
@@ -1437,6 +1574,11 @@ class SendOverride : Feature("Send Override") {
|
||||
clearQueuedOriginalItemRepeats()
|
||||
}
|
||||
} else {
|
||||
totalRepeatCount = repeatCount
|
||||
processedRepeatCount = 0
|
||||
currentRecipientName = recipientNameForTask
|
||||
updateContinuousSendNotification()
|
||||
|
||||
sendRepeatedMediaManual(
|
||||
repeatCount,
|
||||
finalSelectedType,
|
||||
|
||||
@@ -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,54 @@ 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 {
|
||||
textureView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -21,6 +22,10 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
@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)
|
||||
@@ -39,13 +44,15 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
}
|
||||
|
||||
private fun resolveRuleTargets(conversationId: String): Set<String> {
|
||||
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) }
|
||||
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
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
private fun shouldHideConversation(
|
||||
@@ -54,8 +61,18 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
ruleState: RuleState?
|
||||
): Boolean {
|
||||
if (ruleState == null) return false
|
||||
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
|
||||
return if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch
|
||||
|
||||
// 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(
|
||||
@@ -78,9 +95,17 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
|
||||
}
|
||||
|
||||
private fun hideBoundChatFeedRow(view: View) {
|
||||
view.hideViewCompletely()
|
||||
(view.parent as? View)?.hideViewCompletely()
|
||||
(view.parent?.parent as? View)?.hideViewCompletely()
|
||||
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(
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -24,6 +25,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.common.data.ContentType
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
|
||||
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
@@ -87,9 +89,10 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
|
||||
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) 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)))
|
||||
@@ -105,38 +108,37 @@ class MessageIndicators : Feature("Message Indicators") {
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp)
|
||||
.padding(top = 6.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
.padding(top = 6.dp, end = 6.dp),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
val hasEncryption = remember(reader, isGroupConversation) {
|
||||
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
|
||||
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@remember true
|
||||
return@rememberAsyncMutableState true
|
||||
}
|
||||
if (isGroupConversation) return@remember false
|
||||
if (reader.containsPath(4, 5, 1, 3, 1)) return@remember 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 = remember(reader) {
|
||||
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
|
||||
}
|
||||
val sentFromWebApp = remember(reader) {
|
||||
val sentFromWebApp by rememberAsyncMutableState(defaultValue = false) {
|
||||
reader.getVarInt(4, 4, *(if (reader.containsPath(4, 4, 3)) intArrayOf(3, 3, 22, 1) else intArrayOf(11, 22, 1))) == 7L
|
||||
}
|
||||
val sentWithLocation = remember(reader) {
|
||||
val sentWithLocation by rememberAsyncMutableState(defaultValue = false) {
|
||||
reader.getVarInt(4, 4, 11, 17, 5) != null
|
||||
}
|
||||
val sentUsingOvfEditor = remember(reader) {
|
||||
val sentUsingOvfEditor by rememberAsyncMutableState(defaultValue = false) {
|
||||
(reader.getString(4, 4, 11, 12, 1) ?: reader.getString(4, 4, 11, 13, 4, 1, 2, 12, 20, 1)) == "c13129f7-fe4a-44c4-9b9d-e0b26fee8f82"
|
||||
}
|
||||
val sentUsingDirectorMode = remember(reader) {
|
||||
val sentUsingDirectorMode by rememberAsyncMutableState(defaultValue = false) {
|
||||
reader.followPath(4, 4, 11, 28)?.let {
|
||||
(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 = remember(reader) {
|
||||
val sentFromMemories by rememberAsyncMutableState(defaultValue = false) {
|
||||
reader.getVarInt(4, 18) != null
|
||||
|| reader.getString(4, 5, 1, 2)?.contains("/h/") == true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user