11 Commits

Author SHA1 Message Date
ΞTΞRNAL
f3c794fc47 v1.6.4 2026-04-02 00:22:02 +05:30
ΞTΞRNAL
6e6d311c23 v1.6.3 2026-04-01 16:36:35 +05:30
ΞTΞRNAL
a82db6a863 update changelog for v1.6.2 2026-04-01 15:33:17 +05:30
ΞTΞRNAL
9781008b2e fix(PR): Auto Open and Video Downloader Stability fixes by Kaladin
Auto Open and Video Downloader Stability fixes
2026-04-01 14:56:55 +05:30
ΞTΞRNAL
c71e06095d v1.6.2 2026-04-01 14:55:05 +05:30
DarkKnight2122
1fdaf6d55d Auto Open stabilization 2026-04-01 05:08:58 +05:30
DarkKnight2122
8a9258b318 Video Downloader Stability 2026-03-31 17:24:58 +05:30
ΞTΞRNAL
6491288513 v1.6.1 2026-03-29 12:35:34 +05:30
ΞTΞRNAL
33664c1112 v1.6.0 2026-03-29 00:40:08 +05:30
ΞTΞRNAL
bb8e644b4c fix(PR): Select highest quality media by comparing resolution by Issac XT
Fix: Select highest quality media by comparing resolution
2026-03-28 23:53:04 +05:30
Issac
9e5b318120 Fix: Select highest quality media by comparing resolution instead of assuming array order 2026-03-28 11:01:39 +05:30
43 changed files with 5332 additions and 1555 deletions

View File

@@ -7,6 +7,8 @@ import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.core.app.CoreComponentFactory
@@ -30,12 +32,14 @@ import androidx.work.WorkManager
import me.eternal.purrfectsnap.bridge.BridgeService
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.Constants
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.action.EnumAction
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
import me.eternal.purrfectsnap.common.bridge.wrapper.MappingsWrapper
import me.eternal.purrfectsnap.common.config.ModConfig
import me.eternal.purrfectsnap.common.logger.fatalCrash
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.common.util.constantLazyBridge
import me.eternal.purrfectsnap.common.util.getPurgeTime
import me.eternal.purrfectsnap.e2ee.E2EEImplementation
@@ -275,6 +279,67 @@ class RemoteSideContext(
androidContext.startActivity(intent)
}
fun requestSocialSnapshotRefresh(
openSnapchatFirst: Boolean = true,
snapchatWarmupDelayMs: Long = 1200L,
returnDelayMs: Long = 1200L
) {
fun sendSocialSnapshotBroadcast() {
runCatching {
androidContext.sendBroadcast(
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
)
}.onFailure {
log.error("Failed to request latest social snapshot", it)
}
}
if (!openSnapchatFirst) {
sendSocialSnapshotBroadcast()
return
}
val snapchatIntent = androidContext.packageManager
.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
if (snapchatIntent == null) {
shortToast(translation["toast_snapchat_not_installed"])
sendSocialSnapshotBroadcast()
return
}
val returnIntent = Intent(androidContext, MainActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP
)
}
val mainHandler = Handler(Looper.getMainLooper())
runCatching {
androidContext.startActivity(snapchatIntent)
mainHandler.postDelayed(
{
runCatching {
androidContext.startActivity(returnIntent)
}.onFailure {
log.error("Failed to return to PurrfectSnap after Snapchat handoff", it)
}
mainHandler.postDelayed(
{ sendSocialSnapshotBroadcast() },
returnDelayMs
)
},
snapchatWarmupDelayMs
)
}.onFailure {
log.error("Failed to launch Snapchat for social snapshot refresh", it)
sendSocialSnapshotBroadcast()
}
}
private fun scheduleAnnouncementCheck() {
val workManager = WorkManager.getInstance(androidContext)
val constraints = Constraints.Builder()

View File

@@ -504,7 +504,10 @@ class DownloadProcessor (
for (i in 0 until baseUrlNodeList.length) {
val baseUrlNode = baseUrlNodeList.item(i)
val baseUrl = baseUrlNode.textContent
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
// FIX: Only add prefix if it's not already a full URL
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
baseUrlNode.textContent = "${RemoteMediaResolver.CF_ST_CDN_D}$baseUrl"
}
}
val dashOptions = downloadRequest.dashOptions!!

View File

@@ -93,24 +93,6 @@ fun AppDatabase.replaceMessagingData(
executeAsync {
database.beginTransaction()
try {
val friendIds = friends.map { it.userId }.toSet()
val groupIds = groups.map { it.conversationId }.toSet()
getFriends().forEach { friend ->
if (friend.userId !in friendIds) {
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(friend.userId))
database.execSQL("DELETE FROM streaks WHERE id = ?", arrayOf(friend.userId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(friend.userId))
}
}
getGroups().forEach { group ->
if (group.conversationId !in groupIds) {
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(group.conversationId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(group.conversationId))
}
}
friends.forEach { friend ->
database.execSQL(
"INSERT OR REPLACE INTO friends (userId, dmConversationId, displayName, mutableUsername, bitmojiId, selfieId) VALUES (?, ?, ?, ?, ?, ?)",

View File

@@ -9,6 +9,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -43,8 +46,10 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -72,6 +77,8 @@ import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import me.eternal.purrfectsnap.common.ui.TopBarActionButton
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.core.features.impl.experiments.RandomizedDeviceProfile
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@@ -80,6 +87,7 @@ import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.DialogProperties
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
import kotlin.math.max
import kotlin.math.min
@@ -162,6 +170,93 @@ class FeaturesRootSection : Routes.Route() {
}
}
internal fun isRandomizedProfileEnabled(): Boolean {
return context.config.root.experimental.spoof.randomizeDeviceProfile.globalState == true
}
internal fun requestFreshRandomizedProfile() {
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
randomizeConfig.profileGenerationToken.set(UUID.randomUUID().toString())
randomizeConfig.currentProfileSnapshot.set("")
}
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"]
?: "No generated profile is available yet. Enable the feature in Snapchat first.")
}
internal fun isRandomizedProfileActionProperty(propertyName: String): Boolean {
return propertyName == "generate_fresh_profile_action" ||
propertyName == "view_current_profile_action" ||
propertyName == "backup_profile_action" ||
propertyName == "restore_profile_action"
}
internal fun backupRandomizedProfile(onConfigChanged: () -> Unit) {
val profileSnapshot = getRandomizedProfileSnapshot()
if (profileSnapshot.startsWith("No generated profile")) {
context.shortToast(
context.translation["manager.dialogs.randomize_device_profile.empty"]
?: "No generated profile is available yet. Enable the feature in Snapchat first."
)
return
}
activityLauncher {
saveFile("randomized-device-profile.json", "application/json") { uri ->
runCatching {
context.androidContext.contentResolver.openOutputStream(uri.toUri())?.bufferedWriter()?.use {
it.write(profileSnapshot)
} ?: error("Failed to open backup destination")
onConfigChanged()
context.shortToast("Randomized profile backup saved")
}.onFailure {
context.log.error("Failed to back up randomized profile", it)
context.shortToast("Failed to back up randomized profile")
}
}
}
}
internal fun restoreRandomizedProfile(onConfigChanged: () -> Unit) {
activityLauncher {
openFile("application/json") { uri ->
runCatching {
val importedJson = context.androidContext.contentResolver.openInputStream(uri.toUri())
?.bufferedReader()
?.use { it.readText() }
?.trim()
?: error("Failed to read randomized profile backup")
val profile = RandomizedDeviceProfile.fromJson(importedJson)
val generationToken = UUID.randomUUID().toString()
context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
.edit()
.putString("randomized_device_profile", profile.toJson().toString())
.putString("randomized_device_profile_token", generationToken)
.putString("android_id", profile.androidId)
.putString("advertising_id", profile.advertisingId)
.putString("bluetooth_address", profile.bluetoothMacAddress)
.putString("gsf_id", profile.gsfId)
.putString("random_device", profile.deviceInfo.model)
.putString("device_fingerprint", profile.buildFingerprint)
.apply()
val randomizeConfig = context.config.root.experimental.spoof.randomizeDeviceProfile
randomizeConfig.profileGenerationToken.set(generationToken)
randomizeConfig.currentProfileSnapshot.set(profile.toJson().toString(2))
context.config.writeConfig()
onConfigChanged()
context.shortToast("Randomized profile restored. Restart Snapchat to apply it.")
}.onFailure {
context.log.error("Failed to restore randomized profile", it)
context.shortToast("Failed to restore randomized profile")
}
}
}
}
fun navigateToMainRoot() {
routes.navController.navigate(routeInfo.id, NavOptions.Builder()
.setPopUpTo(routes.navController.graph.findStartDestination().id, false)
@@ -330,9 +425,17 @@ class FeaturesRootSection : Routes.Route() {
}
@Composable
internal fun PropertyAction(property: PropertyPair<*>, registerClickCallback: ( () -> Unit ) -> (() -> Unit)) {
internal fun PropertyAction(
property: PropertyPair<*>,
onConfigChanged: () -> Unit,
registerClickCallback: (() -> Unit) -> (() -> Unit)
) {
var showDialog by remember { mutableStateOf(false) }
var dialogComposable by remember { mutableStateOf<@Composable () -> Unit>({}) }
var showRandomProfileProgressDialog by remember { mutableStateOf(false) }
var randomProfileStatus by remember { mutableStateOf("") }
var showCurrentRandomProfileDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
fun registerDialogOnClickCallback() = registerClickCallback { showDialog = true }
@@ -348,7 +451,64 @@ class FeaturesRootSection : Routes.Route() {
}
val propertyValue = property.value
fun persistConfig() = context.config.writeConfig()
val randomProfileEnabled = isRandomizedProfileEnabled()
val isRandomizedProfileContainer = property.name == "randomize_device_profile"
fun persistConfig() {
context.config.writeConfig()
onConfigChanged()
}
if (showRandomProfileProgressDialog) {
AestheticDialog(
onDismissRequest = {},
title = context.translation["manager.dialogs.randomize_device_profile.title"]
?: "Generating random device profile",
text = randomProfileStatus,
icon = Icons.Filled.AutoAwesome,
confirmButtonText = "",
onConfirm = {},
loading = true,
showIcon = false,
showCloseButton = false,
confirmEnabled = false
)
}
if (showCurrentRandomProfileDialog) {
val profileSnapshot = getRandomizedProfileSnapshot()
val clipboardManager = LocalClipboardManager.current
AestheticDialog(
onDismissRequest = { showCurrentRandomProfileDialog = false },
title = context.translation["manager.dialogs.randomize_device_profile.view_title"]
?: "Current randomized profile",
text = "",
icon = Icons.Filled.Visibility,
dismissButtonText = context.translation["button.copy"] ?: "Copy",
onDismiss = {
clipboardManager.setText(AnnotatedString(profileSnapshot))
context.shortToast(
context.translation["manager.dialogs.randomize_device_profile.copied"]
?: "Randomized profile copied"
)
},
confirmButtonText = context.translation["button.positive"],
onConfirm = { showCurrentRandomProfileDialog = false },
showCloseButton = false,
customContent = {
SelectionContainer {
Text(
text = profileSnapshot,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
color = PurrfectPalette.textSecondary,
textAlign = TextAlign.Start
)
}
}
)
}
if (property.key.params.flags.contains(ConfigFlag.USER_IMPORT)) {
registerDialogOnClickCallback()
@@ -518,12 +678,12 @@ class FeaturesRootSection : Routes.Route() {
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = {
onCheckedChange = { requestedState ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
state = state.not()
propertyValue.setAny(state)
state = requestedState
propertyValue.setAny(requestedState)
persistConfig()
},
colors = purrfectSwitchColors()
@@ -566,6 +726,51 @@ class FeaturesRootSection : Routes.Route() {
}
DataProcessors.Type.STRING_MULTIPLE_SELECTION, DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
if (dataType == DataProcessors.Type.STRING && isRandomizedProfileActionProperty(property.name)) {
val actionLabel = when (property.name) {
"generate_fresh_profile_action" -> context.translation[property.key.propertyName()] ?: "Generate Fresh Profile"
"view_current_profile_action" -> context.translation[property.key.propertyName()] ?: "View Current Profile"
"backup_profile_action" -> context.translation[property.key.propertyName()] ?: "Backup Profile"
"restore_profile_action" -> context.translation[property.key.propertyName()] ?: "Restore Profile"
else -> property.name
}
Button(
onClick = {
if (property.name == "generate_fresh_profile_action") {
showRandomProfileProgressDialog = true
coroutineScope.launch {
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.allocating"]
?: "Allocating a randomized device fingerprint"
delay(260)
requestFreshRandomizedProfile()
persistConfig()
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.finalizing"]
?: "Finalizing the all-in-one profile and disabling manual overrides"
delay(260)
showRandomProfileProgressDialog = false
context.shortToast(
context.translation["manager.dialogs.randomize_device_profile.refresh_requested"]
?: "Fresh randomized profile requested. Restart Snapchat to apply it."
)
}
} else if (property.name == "view_current_profile_action") {
showCurrentRandomProfileDialog = true
} else if (property.name == "backup_profile_action") {
backupRandomizedProfile(onConfigChanged)
} else if (property.name == "restore_profile_action") {
restoreRandomizedProfile(onConfigChanged)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.28f),
contentColor = Color.White
)
) {
Text(actionLabel, maxLines = 1)
}
return
}
dialogComposable = {
when (dataType) {
DataProcessors.Type.STRING_MULTIPLE_SELECTION -> {
@@ -573,8 +778,12 @@ class FeaturesRootSection : Routes.Route() {
}
DataProcessors.Type.STRING, DataProcessors.Type.INTEGER, DataProcessors.Type.FLOAT -> {
val isMessageListProperty = property.key.name.endsWith("_messages")
val isSleepWindowProperty = property.key.name.contains("sleep_window")
if (isMessageListProperty) {
alertDialogs.MessageListPropertyDialog(property) { showDialog = false }
} else if (isSleepWindowProperty) {
alertDialogs.AutoOpenScheduleDialog(property as PropertyPair<String>) { showDialog = false }
} else {
alertDialogs.KeyboardInputDialog(property) { showDialog = false }
}
@@ -660,12 +869,35 @@ class FeaturesRootSection : Routes.Route() {
val hapticFeedback = LocalHapticFeedback.current
Switch(
checked = state,
onCheckedChange = {
onCheckedChange = { requestedState ->
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
state = state.not()
container.globalState = state
if (isRandomizedProfileContainer && requestedState) {
showRandomProfileProgressDialog = true
coroutineScope.launch {
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.allocating"]
?: "Allocating a randomized device fingerprint"
delay(260)
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.phase.network"]
?: "Preparing network, locale, and telephony values"
delay(260)
container.globalState = true
state = true
persistConfig()
randomProfileStatus = context.translation["manager.dialogs.randomize_device_profile.done"]
?: "Randomized device profile generated"
delay(220)
showRandomProfileProgressDialog = false
context.log.info("Enabled randomized device profile mode from manager UI")
}
return@Switch
}
state = requestedState
container.globalState = requestedState
if (!requestedState && isRandomizedProfileContainer) {
context.log.info("Disabled randomized device profile mode from manager UI")
}
persistConfig()
},
colors = purrfectSwitchColors()
@@ -722,7 +954,12 @@ class FeaturesRootSection : Routes.Route() {
}
@Composable
internal fun PropertyCard(property: PropertyPair<*>, onOpen: (() -> Unit)? = null) {
internal fun PropertyCard(
property: PropertyPair<*>,
configRefreshNonce: Int,
onConfigChanged: () -> Unit,
onOpen: (() -> Unit)? = null
) {
val isAphelion = remember { context.config.root.global.uiSettings.managerTheme.get() == "APHELION" }
var clickCallback by remember { mutableStateOf<(() -> Unit)?>(null) }
val noticeColorMap = remember {
@@ -736,6 +973,7 @@ class FeaturesRootSection : Routes.Route() {
val versionCheck = remember { property.key.params.versionCheck }
val versionCheckPair = remember(property) { versionCheck?.checkVersion(context.installationSummary.snapchatInfo?.versionCode ?: return@remember null)}
val isComponentDisabled = remember { versionCheckPair != null && versionCheck?.isDisabled == true }
val isInteractionEnabled = !isComponentDisabled
val cardShape = RoundedCornerShape(22.dp)
val interactionSource = remember { MutableInteractionSource() }
@@ -753,8 +991,9 @@ class FeaturesRootSection : Routes.Route() {
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 7.dp)
.graphicsLayer { if (isComponentDisabled) alpha = 0.5f }
.graphicsLayer { if (!isInteractionEnabled) alpha = 0.5f }
.clickable(
enabled = isInteractionEnabled,
interactionSource = interactionSource,
indication = null
) {
@@ -852,7 +1091,7 @@ class FeaturesRootSection : Routes.Route() {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
PropertyAction(property, registerClickCallback = { callback ->
PropertyAction(property, onConfigChanged = onConfigChanged, registerClickCallback = { callback ->
if (property.key.propertyTranslationPath().startsWith("rules.properties")) {
clickCallback = {
routes.manageRuleFeature.navigate {
@@ -1406,6 +1645,7 @@ class FeaturesRootSection : Routes.Route() {
) {
val density = LocalDensity.current
var controlsHeight by remember { mutableStateOf(100.dp) }
var configRefreshNonce by rememberSaveable { mutableStateOf(0) }
val listState = rememberLazyListState()
@@ -1479,7 +1719,12 @@ class FeaturesRootSection : Routes.Route() {
upsertHistory(liveSearchQuery, sharedSearchHistory)
}
} else null
PropertyCard(item, onOpen = onOpen)
PropertyCard(
property = item,
configRefreshNonce = configRefreshNonce,
onConfigChanged = { configRefreshNonce++ },
onOpen = onOpen
)
}
}
item { Spacer(modifier = Modifier.height(12.dp)) }
@@ -1634,9 +1879,14 @@ class FeaturesRootSection : Routes.Route() {
onBack: (() -> Unit)? = null,
) {
PropertiesView(
properties = remember {
properties = remember(configContainer.globalState) {
configContainer.properties.map { (it.key to it.value).toPropertyPair() as PropertyPair<Any> }.filter {
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
!it.key.params.flags.contains(ConfigFlag.HIDDEN) &&
(
configContainer !== context.config.root.experimental.spoof.randomizeDeviceProfile ||
configContainer.globalState == true ||
!isRandomizedProfileActionProperty(it.key.name)
)
}
},
stateKey = stateKey,

View File

@@ -24,11 +24,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.storage.getFriends
import me.eternal.purrfectsnap.storage.getGroups
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.coil.BitmojiImage
@@ -219,39 +219,70 @@ class AddFriendDialog(
var hasFetchError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
coroutineScope.launch {
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else friends
fun applySnapshot(
friends: List<MessagingFriendInfo>,
groups: List<MessagingGroupInfo>
) {
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else {
this
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else {
this
}
}
if (friends.isNotEmpty() || groups.isNotEmpty()) {
timeoutJob?.cancel()
hasFetchError = false
}
}
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
coroutineScope.launch {
applySnapshot(friends, groups)
}
}
withContext(Dispatchers.IO) {
applySnapshot(
context.database.getFriends(descOrder = true),
context.database.getGroups()
)
}
if (context.bridgeService != null) {
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
} else {
context.database.receiveMessagingDataCallback = updateSnapshot
}
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
runCatching {
context.androidContext.sendBroadcast(it)
}.onFailure {
context.log.error("Failed to send broadcast", it)
hasFetchError = true
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
}
}
}
timeoutJob = coroutineScope.launch {
withContext(Dispatchers.IO) {
delay(20000)
hasFetchError = true
delay(25000)
if ((cachedFriends?.isNullOrEmpty() != false) && (cachedGroups?.isNullOrEmpty() != false)) {
hasFetchError = true
}
}
}
}

View File

@@ -38,13 +38,11 @@ import androidx.navigation.NavBackStackEntry
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.common.ReceiversConfig
import me.eternal.purrfectsnap.common.data.MessagingFriendInfo
import me.eternal.purrfectsnap.common.data.MessagingGroupInfo
import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.SnapWidgetBroadcastReceiverHelper
import me.eternal.purrfectsnap.storage.*
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
@@ -63,13 +61,7 @@ class SocialRootSection : Routes.Route() {
}
internal fun requestLatestSnapshot() {
runCatching {
context.androidContext.sendBroadcast(
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}
)
}.onFailure {
context.log.error("Failed to request latest social snapshot", it)
}
context.requestSocialSnapshotRefresh()
}
@Composable

View File

@@ -314,6 +314,7 @@ fun HomeSettings.AphelionSettingsScreen(nav: NavBackStackEntry) {
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_cant_login_button", text = translation["disable_cant_login_button_label"] ?: "Disable Can't Login Button")
}
}
}

View File

@@ -57,8 +57,17 @@ fun SocialRootSection.AphelionSocialScreen(nav: NavBackStackEntry) {
var searchActive by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.database.receiveMessagingDataCallback = { friends, groups ->
friendList = friends
groupList = groups
}
updateScopeLists()
}
DisposableEffect(Unit) {
onDispose {
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
val normalizedQuery = remember(searchQuery) { searchQuery.trim() }
val filteredFriends = remember(friendList, normalizedQuery) {
if (normalizedQuery.isBlank()) {

View File

@@ -921,6 +921,7 @@ object LegacyTheme : ThemeContract {
PreferenceToggle(context.sharedPreferences, key = "disable_feature_loading", text = translation["disable_feature_loading_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_mapper", text = translation["disable_auto_mapper_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_bypass_indicator", text = translation["disable_bypass_indicator_label"])
PreferenceToggle(context.sharedPreferences, key = "disable_cant_login_button", text = translation["disable_cant_login_button_label"] ?: "Disable Can't Login Button")
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -27,6 +28,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
@@ -80,6 +82,146 @@ import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
class AlertDialogs(
private val translation: LocaleWrapper,
){
@Composable
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
val currentValue = property.value.getNullable()?.toString() ?: "[]"
val propertyName = translation[property.key.propertyName()]
MessageListManagerDialog(
title = propertyName ?: "",
messageListJson = currentValue,
onSave = { newValue: String ->
property.value.setAny(newValue)
},
onDismiss = onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AutoOpenScheduleDialog(
property: PropertyPair<String>,
onDismiss: () -> Unit
) {
val windowParts = (property.value.get() as String).split("-")
val startTime = windowParts.getOrNull(0)?.split(":") ?: listOf("23", "00")
val endTime = windowParts.getOrNull(1)?.split(":") ?: listOf("07", "00")
var isEditingEnd by remember { mutableStateOf(false) }
val startState = rememberTimePickerState(
initialHour = startTime.getOrNull(0)?.toIntOrNull() ?: 23,
initialMinute = startTime.getOrNull(1)?.toIntOrNull() ?: 0,
is24Hour = true
)
val endState = rememberTimePickerState(
initialHour = endTime.getOrNull(0)?.toIntOrNull() ?: 7,
initialMinute = endTime.getOrNull(1)?.toIntOrNull() ?: 0,
is24Hour = true
)
DefaultDialogCard {
Column(
modifier = Modifier.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = translation["auto_open_snaps.auto_open_schedule.title"] ?: "Auto Open Scheduler",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.ExtraBold,
color = Color.White
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.05f))
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
val activeColor = PurrfectPalette.glowPrimary.copy(alpha = 0.25f)
val inactiveColor = Color.Transparent
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(if (!isEditingEnd) activeColor else inactiveColor)
.clickable { isEditingEnd = false }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "${translation["auto_open_snaps.auto_open_schedule.start"] ?: "Start"}: ${String.format("%02d:%02d", startState.hour, startState.minute)}",
color = if (!isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
fontWeight = if (!isEditingEnd) FontWeight.Bold else FontWeight.Normal
)
}
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.background(if (isEditingEnd) activeColor else inactiveColor)
.clickable { isEditingEnd = true }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "${translation["auto_open_snaps.auto_open_schedule.end"] ?: "End"}: ${String.format("%02d:%02d", endState.hour, endState.minute)}",
color = if (isEditingEnd) Color.White else Color.White.copy(alpha = 0.6f),
fontWeight = if (isEditingEnd) FontWeight.Bold else FontWeight.Normal
)
}
}
TimePicker(
state = if (isEditingEnd) endState else startState,
colors = TimePickerDefaults.colors(
clockDialColor = Color.White.copy(alpha = 0.05f),
clockDialSelectedContentColor = Color.White,
clockDialUnselectedContentColor = Color.White.copy(alpha = 0.7f),
selectorColor = PurrfectPalette.glowPrimary,
periodSelectorBorderColor = PurrfectPalette.glowPrimary,
periodSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
periodSelectorUnselectedContainerColor = Color.Transparent,
periodSelectorSelectedContentColor = Color.White,
periodSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f),
timeSelectorSelectedContainerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
timeSelectorUnselectedContainerColor = Color.White.copy(alpha = 0.05f),
timeSelectorSelectedContentColor = Color.White,
timeSelectorUnselectedContentColor = Color.White.copy(alpha = 0.7f)
)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)
) {
TextButton(onClick = onDismiss) {
Text(text = translation["button.negative"], color = Color.White)
}
Button(
onClick = {
val startStr = String.format("%02d:%02d", startState.hour, startState.minute)
val endStr = String.format("%02d:%02d", endState.hour, endState.minute)
property.value.setAny("$startStr-$endStr")
onDismiss()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.35f),
contentColor = Color.White
)
) {
Text(text = translation["button.positive"])
}
}
}
}
}
@Composable
fun DefaultDialogCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
val scrollState = rememberScrollState()
@@ -1260,23 +1402,7 @@ class AlertDialogs(
}
}
}
}
@Composable
fun MessageListPropertyDialog(property: PropertyPair<*>, onDismiss: () -> Unit = {}) {
val currentValue = property.value.getNullable()?.toString() ?: "[]"
val propertyName = translation[property.key.propertyName()]
MessageListManagerDialog(
title = propertyName,
messageListJson = currentValue,
onSave = { newValue ->
property.value.setAny(newValue)
},
onDismiss = onDismiss
)
}
}
@Composable
fun MessageListManagerDialog(
@@ -1313,7 +1439,6 @@ class AlertDialogs(
textAlign = TextAlign.Center
)
// Message list
Card(
modifier = Modifier
.fillMaxWidth()
@@ -1329,7 +1454,7 @@ class AlertDialogs(
contentAlignment = Alignment.Center
) {
Text(
text = translation["auto_reply_messages.dialog.no_messages"],
text = translation["bulk_messaging_action.no_messages_found"] ?: "No messages",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -1371,24 +1496,14 @@ class AlertDialogs(
showAddDialog = true
}
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit",
tint = MaterialTheme.colorScheme.primary
)
Icon(Icons.Default.Edit, contentDescription = translation["common.edit"] ?: "Edit", tint = MaterialTheme.colorScheme.primary)
}
IconButton(
onClick = {
messageList = messageList.toMutableList().apply {
removeAt(index)
}
messageList = messageList.toMutableList().apply { removeAt(index) }
}
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
Icon(Icons.Default.Delete, contentDescription = translation["common.delete"] ?: "Delete", tint = MaterialTheme.colorScheme.error)
}
}
}
@@ -1398,7 +1513,6 @@ class AlertDialogs(
}
}
// Add button
Button(
onClick = {
editingIndex = -1
@@ -1408,20 +1522,13 @@ class AlertDialogs(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Icon(
Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(text = translation["auto_reply_messages.dialog.add_message"])
Text(text = translation["common.add"] ?: "Add Message")
}
// Dialog buttons
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1430,17 +1537,14 @@ class AlertDialogs(
) {
Button(
onClick = { onDismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
val gson = com.google.gson.Gson()
val jsonString = gson.toJson(messageList)
onSave(jsonString)
onSave(gson.toJson(messageList))
onDismiss()
}
) {
@@ -1450,7 +1554,6 @@ class AlertDialogs(
}
}
// Add/Edit message dialog
if (showAddDialog) {
StandardDialog(
onDismissRequest = { showAddDialog = false },
@@ -1460,7 +1563,7 @@ class AlertDialogs(
) {
DefaultDialogCard {
Text(
text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["auto_reply_messages.dialog.edit_message"],
text = if (editingIndex == -1) translation["common.add"] ?: "Add Message" else translation["common.edit"] ?: "Edit Message",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
@@ -1472,13 +1575,13 @@ class AlertDialogs(
TextField(
value = editingText,
onValueChange = { editingText = it },
label = { Text(translation["auto_reply_messages.dialog.message_label"]) },
label = { Text(translation["common.message"] ?: "Message") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
minLines = 2,
maxLines = 4,
placeholder = { Text(translation["auto_reply_messages.dialog.message_placeholder"]) }
placeholder = { Text(translation["common.type_message"] ?: "Type message...") }
)
Row(
@@ -1489,32 +1592,22 @@ class AlertDialogs(
) {
Button(
onClick = { showAddDialog = false },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(text = translation["button.cancel"])
}
Button(
onClick = {
if (editingText.isNotBlank()) {
if (editingIndex == -1) {
// Add new message
messageList = messageList.toMutableList().apply {
add(editingText)
}
} else {
// Edit existing message
messageList = messageList.toMutableList().apply {
set(editingIndex, editingText)
}
messageList = messageList.toMutableList().apply {
if (editingIndex == -1) add(editingText) else set(editingIndex, editingText)
}
}
showAddDialog = false
},
enabled = editingText.isNotBlank()
) {
Text(text = if (editingIndex == -1) translation["auto_reply_messages.dialog.add_message"] else translation["button.save"])
Text(text = if (editingIndex == -1) translation["common.add"] ?: "Add" else translation["button.save"])
}
}
}
@@ -1522,3 +1615,4 @@ class AlertDialogs(
}
}
}

View File

@@ -11,7 +11,10 @@ import android.provider.Settings
import android.view.*
import android.view.View.OnAttachStateChangeListener
import androidx.activity.ComponentDialog
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.activity.addCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -47,6 +50,8 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeViewModelStoreOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
@@ -197,6 +202,21 @@ private fun InlineDialog(
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
val interactionSource = remember { MutableInteractionSource() }
val fallbackBackDispatcherOwner = remember(onDismissRequest) {
object : OnBackPressedDispatcherOwner {
private val lifecycleRegistry = LifecycleRegistry(this).apply {
currentState = Lifecycle.State.RESUMED
}
private val dispatcher = OnBackPressedDispatcher(onDismissRequest)
override val lifecycle: Lifecycle
get() = lifecycleRegistry
override val onBackPressedDispatcher: OnBackPressedDispatcher
get() = dispatcher
}
}
val backDispatcherOwner = LocalOnBackPressedDispatcherOwner.current ?: fallbackBackDispatcherOwner
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -212,43 +232,45 @@ private fun InlineDialog(
),
onDismissRequest = onDismissRequest
) {
Box(
modifier = Modifier
.width(screenWidthDp)
.height(screenHeightDp)
.then(
if (dismissOnClickOutside) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onDismissRequest
)
} else {
Modifier
}
)
.semantics { dialog() },
contentAlignment = androidx.compose.ui.Alignment.Center
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
initialScale = 0.92f,
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
),
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
targetScale = 0.96f,
animationSpec = tween(120)
)
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides backDispatcherOwner) {
Box(
modifier = Modifier
.width(screenWidthDp)
.height(screenHeightDp)
.then(
if (dismissOnClickOutside) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onDismissRequest
)
} else {
Modifier
}
)
.semantics { dialog() },
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Box(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {}
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
initialScale = 0.92f,
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
),
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
targetScale = 0.96f,
animationSpec = tween(120)
)
) {
content()
Box(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {}
)
) {
content()
}
}
}
}

View File

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

View File

@@ -1,3 +1,126 @@
## v1.6.4
- New: Performance mode feature!(Smooth & Max)
- Fix: Failed to init feature Device Spoofer for some devices
- Fix: Increase the default queue of auto open snaps
- Fix: user_conversation error spam
## v1.6.3
- Fix: Blank Global Settings page
- New: Backup & Restore option for Randomized Device Profile feature!
## v1.6.2
- New: Randomized Device Profile Feature!
- Show Activation Overlay
- Build Properties
- Device Identity
- Manufacturer + Model
- Brand + Product
- Hardware + Board
- ABI Lists
- Combined ABIs
- Split ABIs
- System Properties
- Build
- Locale
- Telephony
- Build Version
- Fingerprint
- Display
- Host
- Bootloader
- Build Time
- Locale Options
- Locale
- Language
- Region
- Time
- Time Zone ID
- Time Zone Display Name
- Auto Time
- Auto Time Zone
- Telephony Options
- MMS
- User Agent
- Network Identity
- Network Type
- Operator Numeric
- Operator Name
- Country ISO
- SIM Identity
- Country ISO
- Operator Numeric
- Operator Name
- SIM State
- Has ICC Card
- Phone Capabilities
- Phone Count
- Hearing Aid Support
- TTY Support
- World Phone
- Roaming
- SMS + Voice Capability
- Phone Type
- Settings Options
- Secure Settings
- Base
- TTS
- System Settings
- Base
- Bluetooth
- Global Settings
- Base
- Network Options
- Wi-Fi
- SSID
- RSSI
- DNS
- Servers
- Search Domains
- Private DNS
- Captive Portal
- Capability
- Identifier Options
- Android ID
- String Value
- Long Value
- Advertising ID
- Settings Value
- Play Services
- Hardware Addresses
- Wi-Fi MAC
- Bluetooth MAC
- Persistent App Language
- Randomize IP Address
- Generate Fresh Profile
- View Current Profile
- New: Spoof Viewing Gallery Presence
- New: Spoof Reply Camera Presence
- New: I Can See you 2 Friend Tracker Rule
- New: I Can See you 3 Friend Tracker Rule
- New: Disable "Can't Login?" overlay feature
- Fix: Social Tab showing Failed to fetch Data for some accounts
- Fix: Pick a Location Dialog Crash
- Fix: Auto Open Notification duplication when snapchat is killed or force closed(tq to Kaladin)
- Fix: Fixed a logic error where AutoOpenSnaps would ignore per-conversation rules(tq to Kaladin)
- Fix: Auto Open engine stuck in "Monitoring" even when there are snaps in the queue(tq to Kaladin)
- Fix: Auto Open session toggles not working as expected(tq to Kaladin)
- Fix: Auto Open engine is now optimmized to work with the lower end devices to reduce the overheating and excessive lag(tq to Kaladin)
- New: Auto Open Scheduler, a new toggle and a new clock picker to schedule time period for your auto open engine to process(tq to Kaladin)
- New: Auto Open Notification session statistics toggles, now you can choose which stats to show in the notification(tq to Kaladin)
- New: Updated Auto Open notification stats which now includes a tiered "Processing Speed" and "Estimated Finish" stats(tq to Kaladin)
- Fix: Video Downloader is saving the video files from spotlight and stories with extension as ".dat file"(tq to Kaladin)
## v1.6.1
- Fix: Custom Frame Rate
- Fix: Conversation Sound Style
- Fix: Auto Reactions when downloading stories(Removed story thumbnail feature)
## v1.6.0
- New: Conversation Sound Effects!(Sound when you send or receive a msg while in chat)
- New: Call Metadata Notifier!
- New: Pin Messages!(Locally)
- Fix: Select highest quality media by comparing resolution(tq to Issac XT)
## v1.5.9
- New: Block Calls Feature!
- New: Unlock Zoom Limit Feature!

View File

@@ -532,6 +532,18 @@
"title": "Export Sensitive Data?",
"content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)"
},
"randomize_device_profile": {
"title": "Generating random device profile",
"done": "Randomized device profile generated",
"view_title": "Current randomized profile",
"empty": "No generated profile is available yet. Enable the feature in Snapchat first.",
"refresh_requested": "Fresh randomized profile requested. Restart Snapchat to apply it.",
"phase": {
"allocating": "Allocating a randomized device fingerprint",
"network": "Preparing network, locale, and telephony values",
"finalizing": "Finalizing the all-in-one profile and disabling manual overrides"
}
},
"messaging_action": {
"title": "Choose content types to process",
"select_all_button": "Select All"
@@ -898,7 +910,8 @@
"notices": {
"unstable": "\u26a0 Unstable",
"ban_risk": "\u26a0 This feature may cause bans",
"internal_behavior": "\u26a0 This may break Snapchat internal behaviour"
"internal_behavior": "\u26a0 This may break Snapchat internal behaviour",
"randomize_device_profile_override": "Controlled by Randomized Device Profile"
},
"properties": {
"downloader": {
@@ -1174,6 +1187,14 @@
"name": "Hide Bitmoji Presence",
"description": "Prevents your Bitmoji from popping up while in Chat"
},
"spoof_viewing_gallery_presence": {
"name": "Spoof Viewing Gallery Presence",
"description": "Keeps your Bitmoji visible in Chat while viewing chat media"
},
"spoof_reply_camera_presence": {
"name": "Spoof Reply Camera Presence",
"description": "Keeps your Bitmoji visible in Chat while using the reply camera"
},
"hide_typing_notifications": {
"name": "Hide Typing Notifications",
"description": "Prevents anyone from knowing you're typing a message"
@@ -2013,6 +2034,40 @@
"name": "Force Wi-Fi Transport Flag",
"description": "Force network transport to report Wi-Fi instead of mobile data"
},
"randomize_device_profile": {
"name": "Randomized Device Profile",
"description": "Generate and apply a full randomized device, network, locale, and settings profile in one restart-safe profile",
"properties": {
"show_activation_overlay": {
"name": "Show Activation Overlay",
"description": "Show the in-app toast when the randomized profile becomes active"
},
"randomize_ip_address": {
"name": "Randomize IP Address",
"description": "Generate and spoof a randomized IP address whenever a fresh randomized profile is created"
},
"persistent_app_language": {
"name": "Persistent App Language",
"description": "Force Snapchat to stay on a specific supported app language"
},
"generate_fresh_profile_action": {
"name": "Generate Fresh Profile",
"description": "Request a newly generated randomized profile"
},
"view_current_profile_action": {
"name": "View Current Profile",
"description": "Inspect the latest randomized profile snapshot"
},
"backup_profile_action": {
"name": "Backup Profile",
"description": "Export the full randomized profile as a backup file"
},
"restore_profile_action": {
"name": "Restore Profile",
"description": "Import and restore a previously backed up randomized profile"
}
}
},
"spoof_device_id": {
"name": "Spoof Device ID",
"description": "Override the Android ID sent to Snapchat",
@@ -2348,7 +2403,10 @@
"custom_android_id": {
"null": "Use real Android ID"
},
"add_friend_source_spoof": {
"persistent_app_language": {
"system_default": "System Default"
},
"add_friend_source_spoof": {
"added_by_username": "By Username",
"added_by_mention": "By Mention",
"added_by_group_chat": "By Group Chat",
@@ -2965,6 +3023,10 @@
"stopped_speaking": "Stopped Speaking",
"started_peeking": "Started Peeking",
"stopped_peeking": "Stopped Peeking",
"started_using_reply_camera": "Started Using Reply Camera",
"stopped_using_reply_camera": "Stopped Using Reply Camera",
"started_viewing_chat_media": "Started Viewing Chat Media",
"stopped_viewing_chat_media": "Stopped Viewing Chat Media",
"message_read": "Message Read",
"message_deleted": "Message Deleted",
"message_saved": "Message Saved",
@@ -2977,7 +3039,9 @@
"snap_replayed_twice": "Snap Replayed Twice",
"snap_screenshot": "Snap Screenshot",
"snap_screen_record": "Snap Screen Record",
"i_can_see_you": "I Can See You"
"i_can_see_you": "I Can See You",
"i_can_see_you_2": "I Can See You 2",
"i_can_see_you_3": "I Can See You 3"
},
"cleared_from_feed": "Cleared from feed",
"tracker_actions": {
@@ -3186,6 +3250,10 @@
"stopped_speaking": "{friend} stopped speaking in {conversation}",
"started_peeking": "{friend} started peeking in {conversation}",
"stopped_peeking": "{friend} stopped peeking in {conversation}",
"started_using_reply_camera": "{friend} opened the reply camera in {conversation}",
"stopped_using_reply_camera": "{friend} closed the reply camera in {conversation}",
"started_viewing_chat_media": "{friend} started viewing chat media in {conversation}",
"stopped_viewing_chat_media": "{friend} stopped viewing chat media in {conversation}",
"message_read": "{friend} read a message in {conversation}",
"message_deleted": "{friend} deleted a message in {conversation}",
"message_saved": "{friend} saved a message in {conversation}",
@@ -3198,7 +3266,9 @@
"snap_replayed_twice": "{friend} replayed a snap twice in {conversation}",
"snap_screenshot": "{friend} took a screenshot in {conversation}",
"snap_screen_record": "{friend} screen recorded in {conversation}",
"i_can_see_you": "{friend} activity in {conversation}: {details}"
"i_can_see_you": "{friend} activity in {conversation}: {details}",
"i_can_see_you_2": "{friend} gallery activity in {conversation}: {details}",
"i_can_see_you_3": "{friend} reply camera activity in {conversation}: {details}"
},
"friend_mutation_observer": {
"notification_channel_name": "Friend Mutation Observer",
@@ -3351,6 +3421,10 @@
"stopped_speaking": "Stopped speaking",
"started_peeking": "Started peeking",
"stopped_peeking": "Stopped peeking",
"started_using_reply_camera": "Opened reply camera",
"stopped_using_reply_camera": "Closed reply camera",
"started_viewing_chat_media": "Started viewing chat media",
"stopped_viewing_chat_media": "Stopped viewing chat media",
"message_read": "Read message",
"message_deleted": "Deleted message",
"message_saved": "Saved message",
@@ -3402,6 +3476,10 @@
"stopped_speaking": "stopped speaking",
"started_peeking": "started peeking",
"stopped_peeking": "stopped peeking",
"started_using_reply_camera": "opened the reply camera",
"stopped_using_reply_camera": "closed the reply camera",
"started_viewing_chat_media": "started viewing chat media",
"stopped_viewing_chat_media": "stopped viewing chat media",
"message_read": "read a message",
"message_deleted": "deleted a message",
"message_saved": "saved a message",
@@ -3414,7 +3492,9 @@
"snap_replayed_twice": "replayed a snap twice",
"snap_screenshot": "took a screenshot",
"snap_screen_record": "screen recorded",
"i_can_see_you": "was active"
"i_can_see_you": "was active",
"i_can_see_you_2": "was viewing gallery",
"i_can_see_you_3": "was using the reply camera"
}
}
},
@@ -3474,6 +3554,7 @@
"disable_feature_loading_label": "Disable Feature Loading",
"disable_auto_mapper_label": "Disable Auto Mapper",
"disable_bypass_indicator_label": "Disable Bypass Indicator",
"disable_cant_login_button_label": "Disable Can't Login Button",
"friend_list": {
"manage_title": "Manage Friend List",
"export_description": "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.",

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"activity": {
"wrong_apk_title": "Wrong APK installed",
@@ -570,6 +570,19 @@
"title": "Export Sensitive Data?",
"content": "Do you want to export the config with sensitive data? (Such as location coordinates, etc.)"
},
"randomize_device_profile": {
"title": "Generating random device profile",
"done": "Randomized device profile generated",
"view_title": "Current randomized profile",
"copied": "Randomized profile copied",
"empty": "No generated profile is available yet. Enable the feature in Snapchat first.",
"refresh_requested": "Fresh randomized profile requested. Restart Snapchat to apply it.",
"phase": {
"allocating": "Allocating a randomized device fingerprint",
"network": "Preparing network, locale, and telephony values",
"finalizing": "Finalizing the all-in-one profile and disabling manual overrides"
}
},
"messaging_action": {
"title": "Choose content types to process",
"select_all_button": "Select All"
@@ -1238,6 +1251,14 @@
"name": "Hide Bitmoji Presence",
"description": "Prevents your Bitmoji from popping up while in Chat"
},
"spoof_viewing_gallery_presence": {
"name": "Spoof Viewing Gallery Presence",
"description": "Keeps your Bitmoji visible in Chat while viewing chat media"
},
"spoof_reply_camera_presence": {
"name": "Spoof Reply Camera Presence",
"description": "Keeps your Bitmoji visible in Chat while using the reply camera"
},
"hide_typing_notifications": {
"name": "Hide Typing Notifications",
"description": "Prevents anyone from knowing you're typing a message"
@@ -1296,6 +1317,14 @@
"name": "Block Calls",
"description": "Blocks Snapchat call session updates so call UI and incoming call overlays do not appear"
},
"call_metadata_notifier": {
"name": "Call Metadata Notifier",
"description": "Shows a notification with captured call metadata after the call ends"
},
"conversation_sound_effects_style": {
"name": "Conversation Sound Style",
"description": "Choose the sound style used for in-conversation send and receive sounds"
},
"unlimited_conversation_pinning": {
"name": "Unlimited Conversation Pinning",
"description": "Allows you to pin an unlimited amount of conversations locally"
@@ -1653,10 +1682,26 @@
"name": "Auto Open Compact Notification",
"description": "Use a smaller, single-line notification for status updates"
},
"show_progress_bar": {
"name": "Show Progress Bar",
"description": "Display a visual progress bar in the status notification"
},
"show_lifetime_stats": {
"name": "Show Lifetime Statistics",
"description": "Include the total number of snaps opened since installation in the notification"
},
"show_queue_preview": {
"name": "Show Queue Preview",
"description": "Show a list of the most recent snaps waiting in the queue (Expanded only)"
},
"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_when_idle": {
"name": "Auto Open only when Idle",
"description": "Only process queue when the device is not in active use"
"name": "Auto Open Schedule",
"description": "Configure a specific time window where the engine will throttle its speed."
},
"sleep_window": {
"name": "Auto Open Scheduler",
"description": "Define the start and end times for scheduled throttled processing."
},
"pause_during_gaming": { "name": "Pause Auto Open During Gaming", "description": "Automatically slow down processing when a resource intensive app or a game is in the foreground" },
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
@@ -1864,6 +1909,16 @@
"name": "Disable Metrics",
"description": "Blocks sending specific analytic data to Snapchat"
},
"performance_mode": {
"name": "Performance Mode",
"description": "Applies an app-wide speed profile for navigation, preview loading, background work, and camera responsiveness",
"properties": {
"performance_profile": {
"name": "Performance Profile",
"description": "Select how aggressively PurrfectSnap pushes app-wide performance tuning"
}
}
},
"disable_story_sections": {
"name": "Disable Story Sections",
"description": "Removes sections from the Stories page\nMay require a refresh to work properly"
@@ -2111,6 +2166,426 @@
"name": "Force Wi-Fi Transport Flag",
"description": "Force network transport to report Wi-Fi instead of mobile data"
},
"randomize_device_profile": {
"name": "Randomized Device Profile",
"description": "Generate and apply a full randomized device, network, locale, and settings profile",
"properties": {
"show_activation_overlay": {
"name": "Show Activation Overlay",
"description": "Show the in-app toast when the randomized profile becomes active"
},
"randomize_ip_address": {
"name": "Randomize IP Address",
"description": "Generate and spoof a randomized IP address whenever a fresh randomized profile is created"
},
"spoof_build_properties": {
"name": "Spoof Build Properties",
"description": "Apply randomized build fields, fingerprints, and device property values"
},
"build_properties": {
"name": "Build Properties",
"description": "Enable build property spoofing and fine-tune its subsets",
"properties": {
"device_identity": {
"name": "Device Identity",
"description": "Enable device identity spoofing and fine-tune its values",
"properties": {
"manufacturer_model": {
"name": "Manufacturer And Model",
"description": "Randomize the reported manufacturer and model"
},
"brand_product": {
"name": "Brand And Product",
"description": "Randomize the reported brand, device, and product values"
},
"hardware_board": {
"name": "Hardware And Board",
"description": "Randomize the reported hardware and board values"
}
}
},
"build_version": {
"name": "Build Version",
"description": "Enable build version spoofing and fine-tune its values",
"properties": {
"fingerprint": {
"name": "Fingerprint",
"description": "Randomize the reported build fingerprint"
},
"display": {
"name": "Display ID",
"description": "Randomize the reported build display ID"
},
"host": {
"name": "Host",
"description": "Randomize the reported build host"
},
"bootloader": {
"name": "Bootloader",
"description": "Randomize the reported bootloader value"
},
"build_time": {
"name": "Build Time",
"description": "Randomize the reported build timestamp"
}
}
},
"abi_lists": {
"name": "ABI Lists",
"description": "Enable ABI spoofing and fine-tune its values",
"properties": {
"combined_abis": {
"name": "Combined ABI List",
"description": "Randomize the combined supported ABI list"
},
"split_abis": {
"name": "32-bit And 64-bit ABI Lists",
"description": "Randomize the split 32-bit and 64-bit ABI lists"
}
}
},
"system_properties": {
"name": "System Properties",
"description": "Expose randomized values through Android system property lookups",
"properties": {
"build": {
"name": "Build Properties",
"description": "Expose randomized build values through system properties"
},
"locale": {
"name": "Locale Properties",
"description": "Expose randomized locale values through system properties"
},
"telephony": {
"name": "Telephony Properties",
"description": "Expose randomized telephony values through system properties"
}
}
}
}
},
"spoof_locale": {
"name": "Spoof Locale",
"description": "Apply the randomized locale and language hooks"
},
"locale_options": {
"name": "Locale Details",
"description": "Enable locale spoofing and fine-tune its subsets",
"properties": {
"locale": {
"name": "Locale",
"description": "Enable locale spoofing and fine-tune language and region values",
"properties": {
"language": {
"name": "Language",
"description": "Randomize the reported language value"
},
"region": {
"name": "Region",
"description": "Randomize the reported region value"
}
}
},
"time": {
"name": "Time",
"description": "Enable time spoofing and fine-tune time-related values",
"properties": {
"time_zone_id": {
"name": "Time Zone ID",
"description": "Randomize the reported time zone ID"
},
"time_zone_display_name": {
"name": "Time Zone Display Name",
"description": "Randomize the reported time zone display name"
},
"auto_time": {
"name": "Auto Time",
"description": "Randomize the global auto-time setting"
},
"auto_time_zone": {
"name": "Auto Time Zone",
"description": "Randomize the global auto-time-zone setting"
}
}
}
}
},
"spoof_telephony": {
"name": "Spoof Telephony",
"description": "Apply randomized carrier, SIM, and phone capability values"
},
"telephony_options": {
"name": "Telephony Details",
"description": "Enable telephony spoofing and fine-tune its subsets",
"properties": {
"mms": {
"name": "MMS",
"description": "Enable MMS spoofing and fine-tune MMS values",
"properties": {
"user_agent": {
"name": "User Agent",
"description": "Randomize the MMS user agent string"
}
}
},
"network_identity": {
"name": "Network Identity",
"description": "Enable network identity spoofing and fine-tune network values",
"properties": {
"network_type": {
"name": "Network Type",
"description": "Randomize the reported network type"
},
"operator_numeric": {
"name": "Operator Numeric",
"description": "Randomize the reported operator numeric code"
},
"operator_name": {
"name": "Operator Name",
"description": "Randomize the reported operator name"
},
"country_iso": {
"name": "Country ISO",
"description": "Randomize the reported network country ISO"
}
}
},
"sim_identity": {
"name": "SIM Identity",
"description": "Enable SIM identity spoofing and fine-tune SIM values",
"properties": {
"country_iso": {
"name": "Country ISO",
"description": "Randomize the reported SIM country ISO"
},
"operator_numeric": {
"name": "Operator Numeric",
"description": "Randomize the reported SIM operator numeric code"
},
"operator_name": {
"name": "Operator Name",
"description": "Randomize the reported SIM operator name"
},
"sim_state": {
"name": "SIM State",
"description": "Randomize the reported SIM state"
},
"has_icc_card": {
"name": "ICC Card",
"description": "Randomize whether a SIM card is reported as present"
}
}
},
"phone_capabilities": {
"name": "Phone Capabilities",
"description": "Enable phone capability spoofing and fine-tune capability values",
"properties": {
"phone_count": {
"name": "Phone Count",
"description": "Randomize the reported phone count"
},
"hearing_aid": {
"name": "Hearing Aid",
"description": "Randomize hearing aid compatibility support"
},
"tty": {
"name": "TTY",
"description": "Randomize TTY support"
},
"world_phone": {
"name": "World Phone",
"description": "Randomize world phone support"
},
"roaming": {
"name": "Roaming",
"description": "Randomize roaming status"
},
"sms_voice": {
"name": "SMS And Voice",
"description": "Randomize SMS and voice capability support"
},
"phone_type": {
"name": "Phone Type",
"description": "Randomize the reported phone type"
}
}
}
}
},
"spoof_settings": {
"name": "Spoof Settings",
"description": "Apply the randomized Android settings overrides"
},
"settings_options": {
"name": "Settings Details",
"description": "Enable settings spoofing and fine-tune its namespaces",
"properties": {
"secure_settings": {
"name": "Secure Settings",
"description": "Enable secure settings spoofing and fine-tune secure values",
"properties": {
"base": {
"name": "Base",
"description": "Randomize the base secure settings values"
},
"tts": {
"name": "Text To Speech",
"description": "Randomize text-to-speech secure settings values"
}
}
},
"system_settings": {
"name": "System Settings",
"description": "Enable system settings spoofing and fine-tune system values",
"properties": {
"base": {
"name": "Base",
"description": "Randomize the base system settings values"
},
"bluetooth": {
"name": "Bluetooth",
"description": "Randomize Bluetooth-related system settings values"
}
}
},
"global_settings": {
"name": "Global Settings",
"description": "Enable global settings spoofing and fine-tune global values",
"properties": {
"base": {
"name": "Base",
"description": "Randomize the base global settings values"
}
}
}
}
},
"spoof_network": {
"name": "Spoof Network",
"description": "Apply randomized Wi-Fi and DNS network values"
},
"network_options": {
"name": "Network Details",
"description": "Enable network spoofing and fine-tune its subsets",
"properties": {
"wifi": {
"name": "Wi-Fi Info",
"description": "Enable Wi-Fi spoofing and fine-tune Wi-Fi values",
"properties": {
"ssid": {
"name": "SSID",
"description": "Randomize the reported Wi-Fi SSID"
},
"rssi": {
"name": "Signal Strength",
"description": "Randomize the reported Wi-Fi RSSI value"
}
}
},
"dns": {
"name": "DNS",
"description": "Enable DNS spoofing and fine-tune DNS values",
"properties": {
"servers": {
"name": "Servers",
"description": "Randomize the reported DNS server list"
},
"search_domains": {
"name": "Search Domains",
"description": "Randomize the reported DNS search domains"
},
"private_dns": {
"name": "Private DNS",
"description": "Randomize the reported private DNS values"
}
}
},
"captive_portal": {
"name": "Captive Portal",
"description": "Enable captive portal spoofing and fine-tune portal values",
"properties": {
"capability": {
"name": "Capability",
"description": "Randomize the reported captive portal capability"
}
}
}
}
},
"spoof_identifiers": {
"name": "Spoof Identifiers",
"description": "Apply randomized Android ID, advertising ID, and hardware address overrides"
},
"identifier_options": {
"name": "Identifier Details",
"description": "Enable identifier spoofing and fine-tune its subsets",
"properties": {
"android_id": {
"name": "Android ID",
"description": "Enable Android ID spoofing and fine-tune Android ID values",
"properties": {
"string_value": {
"name": "String Value",
"description": "Randomize the string Android ID value"
},
"long_value": {
"name": "Long Value",
"description": "Randomize the long Android ID value"
}
}
},
"advertising_id": {
"name": "Advertising ID",
"description": "Enable advertising ID spoofing and fine-tune advertising ID values",
"properties": {
"settings_value": {
"name": "Settings Value",
"description": "Randomize the advertising ID returned through settings"
},
"play_services": {
"name": "Play Services",
"description": "Randomize the advertising ID returned through Play Services"
}
}
},
"hardware_addresses": {
"name": "Hardware Addresses",
"description": "Enable hardware address spoofing and fine-tune address values",
"properties": {
"wifi_mac": {
"name": "Wi-Fi MAC",
"description": "Randomize the reported Wi-Fi MAC address"
},
"bluetooth_mac": {
"name": "Bluetooth MAC",
"description": "Randomize the reported Bluetooth MAC address"
}
}
}
}
},
"persistent_app_language": {
"name": "Persistent App Language",
"description": "Force Snapchat to stay on a specific supported app language"
},
"generate_fresh_profile_action": {
"name": "Generate Fresh Profile",
"description": "Request a newly generated randomized profile"
},
"view_current_profile_action": {
"name": "View Current Profile",
"description": "Inspect the latest randomized profile snapshot"
},
"backup_profile_action": {
"name": "Backup Profile",
"description": "Export the full randomized profile as a backup file"
},
"restore_profile_action": {
"name": "Restore Profile",
"description": "Import and restore a previously backed up randomized profile"
}
}
},
"spoof_device_id": {
"name": "Spoof Device ID",
"description": "Override the Android ID sent to Snapchat",
@@ -2157,7 +2632,7 @@
}
}
},
"network_optimization": { "name": "Network Optimization", "description": "Optimizes network socket buffers for higher throughput" }, "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": {
@@ -2463,6 +2938,9 @@
"custom_android_id": {
"null": "Use real Android ID"
},
"persistent_app_language": {
"system_default": "System Default"
},
"add_friend_source_spoof": {
"added_by_username": "By Username",
"added_by_mention": "By Mention",
@@ -2516,6 +2994,13 @@
"back_custom_frame_rate": {
"null": "Device default FPS"
},
"conversation_sound_effects_style": {
"disabled": "Disabled",
"imessage": "iMessage",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"subtle": "Subtle"
},
"force_voice_note_format": {
"null": "Use Snapchat default"
},
@@ -2590,6 +3075,11 @@
"custom_image_upload_format": {
"null": "Automatic"
},
"performance_profile": {
"smooth": "Smooth",
"max": "Max",
"null": "Disabled"
},
"update_check_frequency": {
"daily": "Daily",
"weekly": "Weekly",
@@ -2894,7 +3384,9 @@
"download_button": "Download",
"delete_logged_message_button": "Delete Logged Message",
"show_chat_edit_history": "Show Chat Edit History",
"convert_message": "Convert Message"
"convert_message": "Convert Message",
"pin_local_message": "Pin Message Locally",
"unpin_local_message": "Unpin Local Message"
},
"chat_wallpaper_downloader": {
"download_button": "Download Chat Wallpaper"
@@ -3068,6 +3560,7 @@
"positive": "Yes",
"negative": "No",
"cancel": "Cancel",
"copy": "Copy",
"save": "Save",
"open": "Open",
"download": "Download",
@@ -3085,6 +3578,10 @@
"stopped_speaking": "Stopped Speaking",
"started_peeking": "Started Peeking",
"stopped_peeking": "Stopped Peeking",
"started_using_reply_camera": "Started Using Reply Camera",
"stopped_using_reply_camera": "Stopped Using Reply Camera",
"started_viewing_chat_media": "Started Viewing Chat Media",
"stopped_viewing_chat_media": "Stopped Viewing Chat Media",
"message_read": "Message Read",
"message_deleted": "Message Deleted",
"message_saved": "Message Saved",
@@ -3097,7 +3594,9 @@
"snap_replayed_twice": "Snap Replayed Twice",
"snap_screenshot": "Snap Screenshot",
"snap_screen_record": "Snap Screen Record",
"i_can_see_you": "I Can See You"
"i_can_see_you": "I Can See You",
"i_can_see_you_2": "I Can See You 2",
"i_can_see_you_3": "I Can See You 3"
},
"cleared_from_feed": "Cleared from feed",
"tracker_actions": {
@@ -3134,6 +3633,16 @@
"dialog_title": "Start Call",
"dialog_message": "Are you sure you want to start a call?"
},
"call_metadata_notifier": {
"notification_channel_name": "Call Metadata",
"notification_title": "Call Metadata Captured",
"notification_empty": "No call metadata was captured"
},
"local_pinned_messages": {
"banner_title": "Pinned Message",
"pinned_toast": "Pinned message locally",
"unpinned_toast": "Removed local pinned message"
},
"half_swipe_notifier": {
"notification_channel_name": "Half Swipe",
"notification_content_dm": "{friend} just half-swiped into your chat for {duration} seconds",
@@ -3230,6 +3739,11 @@
"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_schedule": {
"title": "Auto Open Scheduler",
"start": "Start",
"end": "End"
},
"error_title": "Auto Open Snaps (Errors)",
"channel_description": "Notifications for auto-opening snaps queue status",
"priority_channel_description": "High priority notifications for auto-opening snaps",
@@ -3250,6 +3764,16 @@
"status_paused": "Paused",
"status_monitoring": "Monitoring",
"status_active": "Active",
"status_failed": "Failed to open {sender}",
"status_retrying": "Retrying in background...",
"processing_speed_full": "Full Speed",
"processing_speed": "Processing Speed",
"speed_throttled": "Throttled",
"estimated_time": "Estimated Time",
"notification_statistics": "STATISTICS",
"notification_total_opened": "Lifetime Opened",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_no_snaps_queue": "Monitoring snaps in background...",
"queue_cleared": "Queue cleared and statistics reset",
"queue_cleared_title": "Queue cleared",
"queue_cleared_reset": "Queue Cleared & Reset",
@@ -3323,6 +3847,10 @@
"stopped_speaking": "{friend} stopped speaking in {conversation}",
"started_peeking": "{friend} started peeking in {conversation}",
"stopped_peeking": "{friend} stopped peeking in {conversation}",
"started_using_reply_camera": "{friend} opened the reply camera in {conversation}",
"stopped_using_reply_camera": "{friend} closed the reply camera in {conversation}",
"started_viewing_chat_media": "{friend} started viewing chat media in {conversation}",
"stopped_viewing_chat_media": "{friend} stopped viewing chat media in {conversation}",
"message_read": "{friend} read a message in {conversation}",
"message_deleted": "{friend} deleted a message in {conversation}",
"message_saved": "{friend} saved a message in {conversation}",
@@ -3335,7 +3863,9 @@
"snap_replayed_twice": "{friend} replayed a snap twice in {conversation}",
"snap_screenshot": "{friend} took a screenshot in {conversation}",
"snap_screen_record": "{friend} screen recorded in {conversation}",
"i_can_see_you": "{friend} activity in {conversation}: {details}"
"i_can_see_you": "{friend} activity in {conversation}: {details}",
"i_can_see_you_2": "{friend} gallery activity in {conversation}: {details}",
"i_can_see_you_3": "{friend} reply camera activity in {conversation}: {details}"
},
"friend_mutation_observer": {
"notification_channel_name": "Friend Mutation Observer",
@@ -3495,9 +4025,13 @@
"started_typing": "Started typing",
"stopped_typing": "Stopped typing",
"started_speaking": "Started speaking",
"stopped_speaking": "Stopped speaking",
"stopped_speaking": "Stopped speaking",
"started_peeking": "Started peeking",
"stopped_peeking": "Stopped peeking",
"started_using_reply_camera": "Opened reply camera",
"stopped_using_reply_camera": "Closed reply camera",
"started_viewing_chat_media": "Started viewing chat media",
"stopped_viewing_chat_media": "Stopped viewing chat media",
"message_read": "Read message",
"message_deleted": "Deleted message",
"message_saved": "Saved message",
@@ -3549,6 +4083,10 @@
"stopped_speaking": "stopped speaking",
"started_peeking": "started peeking",
"stopped_peeking": "stopped peeking",
"started_using_reply_camera": "opened the reply camera",
"stopped_using_reply_camera": "closed the reply camera",
"started_viewing_chat_media": "started viewing chat media",
"stopped_viewing_chat_media": "stopped viewing chat media",
"message_read": "read a message",
"message_deleted": "deleted a message",
"message_saved": "saved a message",
@@ -3561,7 +4099,9 @@
"snap_replayed_twice": "replayed a snap twice",
"snap_screenshot": "took a screenshot",
"snap_screen_record": "screen recorded",
"i_can_see_you": "was active"
"i_can_see_you": "was active",
"i_can_see_you_2": "was viewing gallery",
"i_can_see_you_3": "was using the reply camera"
}
}
},
@@ -3621,6 +4161,7 @@
"disable_feature_loading_label": "Disable Feature Loading",
"disable_auto_mapper_label": "Disable Auto Mapper",
"disable_bypass_indicator_label": "Disable Bypass Indicator",
"disable_cant_login_button_label": "Disable Can't Login Button",
"friend_list": {
"manage_title": "Manage Friend List",
"export_description": "Export friends allows you to save a list of your friends' IDs in a text file. Importing from a file will display the friends in a list where you can add them.",
@@ -3659,17 +4200,25 @@
"include_saved_locations_description": "Export your saved location coordinates",
"common": {
"cancel": "Cancel",
"save": "Save",
"edit": "Edit",
"delete": "Delete",
"close": "Close",
"add": "Add",
"ok": "OK",
"quit": "Quit",
"done": "Done",
"back": "Back",
"message": "Message",
"type_message": "Type message...",
"unknown": "Unknown",
"unknown_error": "Unknown error",
"not_available": "N/A",
"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",

View File

@@ -63,7 +63,7 @@ class Experimental : ConfigContainer() {
}
val nativeHooks = container("native_hooks", NativeHooks()) { icon = Icons.Default.Memory; requireRestart() }
val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; addNotices(FeatureNotice.BAN_RISK); requireRestart() }
val spoof = container("spoof", Spoof()) { icon = Icons.Default.Fingerprint ; requireRestart() }
val convertMessageLocally = boolean("convert_message_locally") { requireRestart() }
val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); }

View File

@@ -38,9 +38,16 @@ class Global : ConfigContainer() {
val customUploadImageFormat = unique("custom_image_upload_format", "jpeg", "png", "webp") { requireRestart(); addFlags(ConfigFlag.NO_TRANSLATE) }
}
inner class PerformanceModeConfig : ConfigContainer() {
val profile = unique("performance_profile", "smooth", "max") {
requireRestart()
}
}
val betterLocation = container("better_location", BetterLocationConfig())
val snapchatPlus = unique("snapchat_plus", "not_subscribed", "basic", "ad_free") { requireRestart() }
val mediaUploadQualityConfig = container("media_upload_quality", MediaUploadQualityConfig())
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() }

View File

@@ -166,7 +166,7 @@ class MessagingTweaks : ConfigContainer() {
val maxDelayMs = integer("max_delay_ms", defaultValue = 100) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && it.toInt() > minDelay.get() }
}
val queueSize = integer("queue_size", defaultValue = 10) {
val queueSize = integer("queue_size", defaultValue = 1000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
}
val retryAttempts = integer("retry_attempts", defaultValue = 5) {
@@ -176,12 +176,19 @@ class MessagingTweaks : ConfigContainer() {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
}
val compactNotification = boolean("compact_notification", false)
val showProgressBar = boolean("show_progress_bar", true)
val showLifetimeStats = boolean("show_lifetime_stats", false)
val showQueuePreview = boolean("show_queue_preview", true)
// Resource Intelligence: Smart triggers for battery and data safety
val onlyOnWifi = boolean("only_on_wifi", false)
val onlyWhenIdle = boolean("only_when_idle", false)
val pauseDuringGaming = boolean("pause_during_gaming", false)
val safeProcessing = boolean("safe_processing", true)
val onlyWhenIdle = boolean("only_when_idle", false)
val sleepWindow = string("sleep_window", defaultValue = "23:00-07:00") {
addFlags(ConfigFlag.NO_DISABLE_KEY)
inputCheck = { it.matches(Regex("^([01]\\d|2[0-3]):([0-5]\\d)-([01]\\d|2[0-3]):([0-5]\\d)$")) }
}
}
class AutoDeleteSentMessagesConfig : ConfigContainer(hasGlobalState = true) {
@@ -207,6 +214,8 @@ class MessagingTweaks : ConfigContainer() {
val preventStoryRewatchIndicator = boolean("prevent_story_rewatch_indicator") { requireRestart() }
val hidePeekAPeek = boolean("hide_peek_a_peek")
val hideBitmojiPresence = boolean("hide_bitmoji_presence")
val spoofViewingGalleryPresence = boolean("spoof_viewing_gallery_presence")
val spoofReplyCameraPresence = boolean("spoof_reply_camera_presence")
val hideTypingNotifications = boolean("hide_typing_notifications")
val unlimitedSnapViewTime = boolean("unlimited_snap_view_time")
val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() }
@@ -223,6 +232,13 @@ class MessagingTweaks : ConfigContainer() {
val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()}
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
val blockCalls = boolean("block_calls") { requireRestart() }
val callMetadataNotifier = boolean("call_metadata_notifier") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "disabled", "imessage", "telegram", "whatsapp", "subtle") {
requireRestart()
customOptionTranslationPath = "conversation_sound_effects_style"
addFlags(ConfigFlag.NO_TRANSLATE)
addFlags(ConfigFlag.NO_DISABLE_KEY)
}.apply { set("disabled") }
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() }
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",
@@ -317,4 +333,3 @@ class MessagingTweaks : ConfigContainer() {
val instantTranslation = container("instant_translation", InstantTranslationConfig()) { requireRestart() }
}

View File

@@ -4,10 +4,195 @@ import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.ConfigFlag
class Spoof : ConfigContainer(hasGlobalState = true) {
companion object {
val supportedSnapchatLanguages = listOf(
"ar", "bn", "bn-BD", "bn-IN", "da", "de", "el", "en-GB", "es", "es-AR", "es-ES", "es-MX",
"fi", "fil", "fil-PH", "fr", "gu", "gu-IN", "hi", "hi-IN", "in", "it", "ja", "kn", "kn-IN",
"ko", "ml", "ml-IN", "mr", "mr-IN", "ms", "ms-MY", "nb", "nl", "pa", "pa-IN", "pl", "pt",
"pt-PT", "ro", "ru", "sv", "ta", "ta-IN", "te", "te-IN", "th", "th-TH", "tr", "ur", "ur-PK",
"vi", "vi-VN", "zh", "zh-CN", "zh-TW"
)
}
inner class RandomizedDeviceProfileConfig : ConfigContainer(hasGlobalState = true) {
inner class RandomizedBuildVersionConfig : ConfigContainer(hasGlobalState = true) {
val fingerprint = boolean("fingerprint", defaultValue = true) { requireRestart() }
val display = boolean("display", defaultValue = true) { requireRestart() }
val host = boolean("host", defaultValue = true) { requireRestart() }
val bootloader = boolean("bootloader", defaultValue = true) { requireRestart() }
val buildTime = boolean("build_time", defaultValue = true) { requireRestart() }
}
inner class RandomizedDeviceIdentityConfig : ConfigContainer(hasGlobalState = true) {
val manufacturerModel = boolean("manufacturer_model", defaultValue = true) { requireRestart() }
val brandProduct = boolean("brand_product", defaultValue = true) { requireRestart() }
val hardwareBoard = boolean("hardware_board", defaultValue = true) { requireRestart() }
}
inner class RandomizedAbiListsConfig : ConfigContainer(hasGlobalState = true) {
val combinedAbis = boolean("combined_abis", defaultValue = true) { requireRestart() }
val splitAbis = boolean("split_abis", defaultValue = true) { requireRestart() }
}
inner class RandomizedSystemPropertiesConfig : ConfigContainer(hasGlobalState = true) {
val build = boolean("build", defaultValue = true) { requireRestart() }
val locale = boolean("locale", defaultValue = true) { requireRestart() }
val telephony = boolean("telephony", defaultValue = true) { requireRestart() }
}
inner class RandomizedBuildPropertiesConfig : ConfigContainer(hasGlobalState = true) {
val deviceIdentity = container("device_identity", RandomizedDeviceIdentityConfig().apply { globalState = true })
val abiLists = container("abi_lists", RandomizedAbiListsConfig().apply { globalState = true })
val systemProperties = container("system_properties", RandomizedSystemPropertiesConfig().apply { globalState = true })
val buildVersion = container("build_version", RandomizedBuildVersionConfig().apply { globalState = true })
}
inner class RandomizedLocaleValueConfig : ConfigContainer(hasGlobalState = true) {
val language = boolean("language", defaultValue = true) { requireRestart() }
val region = boolean("region", defaultValue = true) { requireRestart() }
}
inner class RandomizedTimeConfig : ConfigContainer(hasGlobalState = true) {
val timeZoneId = boolean("time_zone_id", defaultValue = false) { requireRestart() }
val timeZoneDisplayName = boolean("time_zone_display_name", defaultValue = false) { requireRestart() }
val autoTime = boolean("auto_time", defaultValue = false) { requireRestart() }
val autoTimeZone = boolean("auto_time_zone", defaultValue = false) { requireRestart() }
}
inner class RandomizedLocaleConfig : ConfigContainer(hasGlobalState = true) {
val locale = container("locale", RandomizedLocaleValueConfig().apply { globalState = true })
val time = container("time", RandomizedTimeConfig().apply { globalState = true })
}
inner class RandomizedMmsConfig : ConfigContainer(hasGlobalState = true) {
val userAgent = boolean("user_agent", defaultValue = true) { requireRestart() }
}
inner class RandomizedNetworkIdentityConfig : ConfigContainer(hasGlobalState = true) {
val networkType = boolean("network_type", defaultValue = true) { requireRestart() }
val operatorNumeric = boolean("operator_numeric", defaultValue = true) { requireRestart() }
val operatorName = boolean("operator_name", defaultValue = true) { requireRestart() }
val countryIso = boolean("country_iso", defaultValue = true) { requireRestart() }
}
inner class RandomizedSimIdentityConfig : ConfigContainer(hasGlobalState = true) {
val countryIso = boolean("country_iso", defaultValue = true) { requireRestart() }
val operatorNumeric = boolean("operator_numeric", defaultValue = true) { requireRestart() }
val operatorName = boolean("operator_name", defaultValue = true) { requireRestart() }
val simState = boolean("sim_state", defaultValue = true) { requireRestart() }
val hasIccCard = boolean("has_icc_card", defaultValue = true) { requireRestart() }
}
inner class RandomizedPhoneCapabilitiesConfig : ConfigContainer(hasGlobalState = true) {
val phoneCount = boolean("phone_count", defaultValue = true) { requireRestart() }
val hearingAid = boolean("hearing_aid", defaultValue = true) { requireRestart() }
val tty = boolean("tty", defaultValue = true) { requireRestart() }
val worldPhone = boolean("world_phone", defaultValue = true) { requireRestart() }
val roaming = boolean("roaming", defaultValue = true) { requireRestart() }
val smsVoice = boolean("sms_voice", defaultValue = true) { requireRestart() }
val phoneType = boolean("phone_type", defaultValue = true) { requireRestart() }
}
inner class RandomizedTelephonyConfig : ConfigContainer(hasGlobalState = true) {
val mms = container("mms", RandomizedMmsConfig().apply { globalState = true })
val networkIdentity = container("network_identity", RandomizedNetworkIdentityConfig().apply { globalState = true })
val simIdentity = container("sim_identity", RandomizedSimIdentityConfig().apply { globalState = true })
val phoneCapabilities = container("phone_capabilities", RandomizedPhoneCapabilitiesConfig().apply { globalState = true })
}
inner class RandomizedSecureSettingsConfig : ConfigContainer(hasGlobalState = true) {
val base = boolean("base", defaultValue = true) { requireRestart() }
val tts = boolean("tts", defaultValue = true) { requireRestart() }
}
inner class RandomizedSystemSettingsConfig : ConfigContainer(hasGlobalState = true) {
val base = boolean("base", defaultValue = true) { requireRestart() }
val bluetooth = boolean("bluetooth", defaultValue = true) { requireRestart() }
}
inner class RandomizedGlobalSettingsConfig : ConfigContainer(hasGlobalState = true) {
val base = boolean("base", defaultValue = true) { requireRestart() }
}
inner class RandomizedSettingsConfig : ConfigContainer(hasGlobalState = true) {
val secureSettings = container("secure_settings", RandomizedSecureSettingsConfig().apply { globalState = true })
val systemSettings = container("system_settings", RandomizedSystemSettingsConfig().apply { globalState = true })
val globalSettings = container("global_settings", RandomizedGlobalSettingsConfig().apply { globalState = true })
}
inner class RandomizedWifiConfig : ConfigContainer(hasGlobalState = true) {
val ssid = boolean("ssid", defaultValue = true) { requireRestart() }
val rssi = boolean("rssi", defaultValue = true) { requireRestart() }
}
inner class RandomizedDnsConfig : ConfigContainer(hasGlobalState = true) {
val servers = boolean("servers", defaultValue = true) { requireRestart() }
val searchDomains = boolean("search_domains", defaultValue = true) { requireRestart() }
val privateDns = boolean("private_dns", defaultValue = true) { requireRestart() }
}
inner class RandomizedCaptivePortalConfig : ConfigContainer(hasGlobalState = true) {
val capability = boolean("capability", defaultValue = true) { requireRestart() }
}
inner class RandomizedNetworkConfig : ConfigContainer(hasGlobalState = true) {
val wifi = container("wifi", RandomizedWifiConfig().apply { globalState = true })
val dns = container("dns", RandomizedDnsConfig().apply { globalState = true })
val captivePortal = container("captive_portal", RandomizedCaptivePortalConfig().apply { globalState = true })
}
inner class RandomizedAndroidIdConfig : ConfigContainer(hasGlobalState = true) {
val stringValue = boolean("string_value", defaultValue = true) { requireRestart() }
val longValue = boolean("long_value", defaultValue = true) { requireRestart() }
}
inner class RandomizedAdvertisingIdConfig : ConfigContainer(hasGlobalState = true) {
val settingsValue = boolean("settings_value", defaultValue = true) { requireRestart() }
val playServices = boolean("play_services", defaultValue = true) { requireRestart() }
}
inner class RandomizedHardwareAddressesConfig : ConfigContainer(hasGlobalState = true) {
val wifiMac = boolean("wifi_mac", defaultValue = true) { requireRestart() }
val bluetoothMac = boolean("bluetooth_mac", defaultValue = true) { requireRestart() }
}
inner class RandomizedIdentifiersConfig : ConfigContainer(hasGlobalState = true) {
val androidId = container("android_id", RandomizedAndroidIdConfig().apply { globalState = true })
val advertisingId = container("advertising_id", RandomizedAdvertisingIdConfig().apply { globalState = true })
val hardwareAddresses = container("hardware_addresses", RandomizedHardwareAddressesConfig().apply { globalState = true })
}
val showActivationOverlay = boolean("show_activation_overlay", defaultValue = false)
val randomizeIpAddress = boolean("randomize_ip_address", defaultValue = false) { requireRestart() }
val buildProperties = container("build_properties", RandomizedBuildPropertiesConfig().apply { globalState = true })
val localeOptions = container("locale_options", RandomizedLocaleConfig().apply { globalState = true })
val telephonyOptions = container("telephony_options", RandomizedTelephonyConfig().apply { globalState = true })
val settingsOptions = container("settings_options", RandomizedSettingsConfig().apply { globalState = true })
val networkOptions = container("network_options", RandomizedNetworkConfig().apply { globalState = true })
val identifierOptions = container("identifier_options", RandomizedIdentifiersConfig().apply { globalState = true })
val persistentAppLanguage = unique("persistent_app_language", *supportedSnapchatLanguages.toTypedArray()) {
requireRestart()
addFlags(ConfigFlag.NO_TRANSLATE)
disabledKey = "system_default"
customOptionTranslationPath = "features.options.persistent_app_language"
}
val generateFreshProfileAction = string("generate_fresh_profile_action")
val viewCurrentProfileAction = string("view_current_profile_action")
val backupProfileAction = string("backup_profile_action")
val restoreProfileAction = string("restore_profile_action")
val profileGenerationToken = string("profile_generation_token") {
addFlags(ConfigFlag.HIDDEN)
}
val currentProfileSnapshot = string("current_profile_snapshot") {
addFlags(ConfigFlag.HIDDEN)
}
}
inner class SpoofDeviceIdConfig : ConfigContainer() {
val spoofAndroidId = boolean("spoof_android_id") { requireRestart() }
val spoofAndroidId = boolean("spoof_android_id") { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
val customAndroidId = string("custom_android_id") {
requireRestart()
addFlags(ConfigFlag.HIDDEN)
inputCheck = { it.isEmpty() || (it.length == 16 && it.all { c -> c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F' }) }
}
}
@@ -16,8 +201,9 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
val removeVpnTransportFlag = boolean("remove_vpn_transport_flag") { requireRestart() }
val removeMockLocationFlag = boolean("remove_mock_location_flag") { requireRestart() }
val forceWifiTransportFlag = boolean("force_wifi_transport_flag") { requireRestart() }
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart() }
val spoofDevice = boolean("spoof_device") { requireRestart() }
val randomizeDeviceProfile = container("randomize_device_profile", RandomizedDeviceProfileConfig()) { requireRestart() }
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
val spoofDevice = boolean("spoof_device") { requireRestart(); addFlags(ConfigFlag.HIDDEN) }
val deviceModel = unique("device_model",
"none",
"random",
@@ -38,6 +224,7 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
"realme GT 6"
) {
requireRestart()
addFlags(ConfigFlag.HIDDEN)
customOptionTranslationPath = "features.options.device_model"
}
}

View File

@@ -21,6 +21,8 @@ enum class FileType(
JPG("jpg", "image/jpg",false, true, false),
ZIP("zip", "application/zip", false, false, false),
WEBP("webp", "image/webp", false, true, false),
HEIC("heic", "image/heic", false, true, false),
HEIF("heif", "image/heif", false, true, false),
MPD("mpd", "text/xml", false, false, false),
UNKNOWN("dat", "application/octet-stream", false, false, false);
@@ -64,6 +66,11 @@ enum class FileType(
}
val majorBrand = String(array, 8, 4, Charsets.US_ASCII).trim('\u0000').lowercase()
// Explicitly exclude known IMAGE-only brands to prevent false positives (HEIC/HEIF)
val imageBrands = setOf("heic", "heix", "hevc", "hevx", "mif1", "msf1")
if (majorBrand in imageBrands) return false
return majorBrand in setOf(
"mp41",
"mp42",
@@ -75,14 +82,14 @@ enum class FileType(
"iso6",
"avc1",
"dash",
"mif1",
"cmfc",
"msnv",
"3gp4",
"3gp5",
"3gp6",
"3g2a",
"3g2b"
)
) || majorBrand.isNotEmpty() // FALLBACK: If it has the ftyp box and isn't a known image brand, it's a video
}
fun fromFile(file: File): FileType {
@@ -97,8 +104,24 @@ enum class FileType(
val headerBytes = ByteArray(16)
System.arraycopy(array, 0, headerBytes, 0, 16)
val hex = bytesToHex(headerBytes)
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value
?: if (looksLikeIsoBmffVideo(headerBytes)) MP4 else UNKNOWN
// 1. Check strict signatures
fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value?.let { return it }
// 2. Check ISO BMFF container type
val majorBrand = if (headerBytes.size >= 12 &&
headerBytes[4] == 'f'.code.toByte() && headerBytes[5] == 't'.code.toByte() &&
headerBytes[6] == 'y'.code.toByte() && headerBytes[7] == 'p'.code.toByte()) {
String(headerBytes, 8, 4, Charsets.US_ASCII).trim('\u0000').lowercase()
} else null
if (majorBrand != null) {
if (majorBrand in setOf("heic", "heix")) return HEIC
if (majorBrand in setOf("mif1", "msf1")) return HEIF
if (looksLikeIsoBmffVideo(headerBytes)) return MP4
}
return UNKNOWN
}
fun fromInputStream(inputStream: InputStream): FileType {

View File

@@ -9,7 +9,9 @@ data class FriendPresenceState(
val typing: Boolean,
val wasTyping: Boolean,
val speaking: Boolean,
val peeking: Boolean
val peeking: Boolean,
val usingReplyCamera: Boolean,
val viewingChatMedia: Boolean
)
open class SessionEvent(
@@ -44,6 +46,8 @@ enum class SessionEventType(
SNAP_SCREENSHOT("snap_screenshot"),
SNAP_SCREEN_RECORD("snap_screen_record"),
I_CAN_SEE_YOU("i_can_see_you"),
I_CAN_SEE_YOU_2("i_can_see_you_2"),
I_CAN_SEE_YOU_3("i_can_see_you_3"),
}
enum class TrackerEventType(
@@ -58,6 +62,10 @@ enum class TrackerEventType(
STOPPED_SPEAKING("stopped_speaking"),
STARTED_PEEKING("started_peeking"),
STOPPED_PEEKING("stopped_peeking"),
STARTED_USING_REPLY_CAMERA("started_using_reply_camera"),
STOPPED_USING_REPLY_CAMERA("stopped_using_reply_camera"),
STARTED_VIEWING_CHAT_MEDIA("started_viewing_chat_media"),
STOPPED_VIEWING_CHAT_MEDIA("stopped_viewing_chat_media"),
// mcs events
MESSAGE_READ("message_read"),
@@ -73,6 +81,8 @@ enum class TrackerEventType(
SNAP_SCREENSHOT("snap_screenshot"),
SNAP_SCREEN_RECORD("snap_screen_record"),
I_CAN_SEE_YOU("i_can_see_you"),
I_CAN_SEE_YOU_2("i_can_see_you_2"),
I_CAN_SEE_YOU_3("i_can_see_you_3"),
}

View File

@@ -319,6 +319,7 @@ class SecurityFeatures(
loginHelpComposable = {
var showDialog by remember { mutableStateOf(false) }
var isLoginScreen by remember { mutableStateOf(false) }
val disableHelpButton = context.bridgeClient.getDebugProp("disable_cant_login_button", "false") == "true"
LaunchedEffect(Unit) {
while (true) {
@@ -329,13 +330,13 @@ class SecurityFeatures(
}
}
if (isLoginScreen) {
if (isLoginScreen && !disableHelpButton) {
LoginSignupHelpButton(
onClick = { showDialog = true }
)
}
if (isLoginScreen && showDialog) {
if (isLoginScreen && !disableHelpButton && showDialog) {
LoginSignupHelpDialog(
onDismiss = { showDialog = false }
)
@@ -433,6 +434,9 @@ class SecurityFeatures(
context.features.addActivityCreateListener { activity ->
if (!activity.javaClass.name.endsWith("LoginSignupActivity")) return@addActivityCreateListener
if (context.bridgeClient.getDebugProp("disable_cant_login_button", "false") == "true") {
return@addActivityCreateListener
}
activity.findViewById<ViewGroup>(android.R.id.content).apply {
visibility = ViewGroup.INVISIBLE

View File

@@ -30,6 +30,7 @@ import me.eternal.purrfectsnap.common.data.SocialScope
import me.eternal.purrfectsnap.common.ui.OverlayType
import me.eternal.purrfectsnap.common.util.toSerialized
import me.eternal.purrfectsnap.core.ModContext
import java.nio.charset.StandardCharsets
import java.util.concurrent.Executors
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
@@ -234,10 +235,48 @@ class BridgeClient(
fun passGroupsAndFriends(groups: List<MessagingGroupInfo>, friends: List<MessagingFriendInfo>) =
safeServiceCall {
service.passGroupsAndFriends(
groups.mapNotNull { it.toSerialized() },
friends.mapNotNull { it.toSerialized() }
val serializedGroups = groups.mapNotNull { it.toSerialized() }
val serializedFriends = friends.mapNotNull { it.toSerialized() }
val maxChunkBytes = 128 * 1024
fun chunkSerialized(values: List<String>): List<List<String>> {
if (values.isEmpty()) return listOf(emptyList())
val result = mutableListOf<List<String>>()
val currentChunk = mutableListOf<String>()
var currentSize = 0
values.forEach { value ->
val valueSize = value.toByteArray(StandardCharsets.UTF_8).size + 32
if (currentChunk.isNotEmpty() && currentSize + valueSize > maxChunkBytes) {
result += currentChunk.toList()
currentChunk.clear()
currentSize = 0
}
currentChunk += value
currentSize += valueSize
}
if (currentChunk.isNotEmpty()) {
result += currentChunk.toList()
}
return result
}
val groupChunks = chunkSerialized(serializedGroups)
val friendChunks = chunkSerialized(serializedFriends)
val chunkCount = maxOf(groupChunks.size, friendChunks.size)
context.log.info(
"Sending social snapshot in $chunkCount chunk(s): " +
"${serializedGroups.size} groups, ${serializedFriends.size} friends"
)
repeat(chunkCount) { index ->
service.passGroupsAndFriends(
groupChunks.getOrElse(index) { emptyList() },
friendChunks.getOrElse(index) { emptyList() }
)
}
}
fun getRules(targetUuid: String): List<MessagingRuleType> = safeServiceCall {

View File

@@ -47,6 +47,14 @@ class DatabaseAccess(
} == true
}
private val hasArroyoUserConversationTable by lazy {
useDatabase(DatabaseType.ARROYO)?.performOperation {
safeRawQuery("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'user_conversation'")?.use { query ->
query.moveToFirst() && query.getStringOrNull("name") == "user_conversation"
}
} == true
}
private fun useDatabase(database: DatabaseType, writeMode: Boolean = false): SQLiteDatabase? {
// only cache read-only databases
if (!writeMode && openedDatabases.containsKey(database) && openedDatabases[database]?.isOpen == true) {
@@ -148,6 +156,10 @@ class DatabaseAccess(
}?.toMutableMap() ?: mutableMapOf()
}
if (!hasArroyoUserConversationTable) {
return@lazy mutableMapOf()
}
(useDatabase(DatabaseType.ARROYO)?.performOperation {
safeRawQuery(
"SELECT client_conversation_id, conversation_type, user_id FROM user_conversation WHERE user_id != ?",
@@ -354,7 +366,7 @@ class DatabaseAccess(
}
fun getConversationType(conversationId: String): Int? {
if (hasArroyoConversationTable) {
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
return getFeedEntryByConversationId(conversationId)?.conversationType
}
@@ -372,7 +384,9 @@ class DatabaseAccess(
}
fun getDMConversationId(userId: String): String? {
if (hasArroyoConversationTable) {
friendDMsCache[userId]?.let { return it }
if (hasArroyoConversationTable || !hasArroyoUserConversationTable) {
return friendDMsCache[userId]
}
@@ -408,6 +422,10 @@ class DatabaseAccess(
}
}
if (!hasArroyoUserConversationTable) {
return getFeedEntryByConversationId(conversationId)?.participants
}
return useDatabase(DatabaseType.ARROYO)?.performOperation {
safeRawQuery(
"SELECT user_id FROM user_conversation WHERE client_conversation_id = ?",

View File

@@ -99,6 +99,7 @@ class FeatureManager(
MeoPasscodeBypass(),
AppLock(),
CameraTweaks(),
PerformanceMode(),
InfiniteStoryBoost(),
PinConversations(),
DeviceSpooferHook(),
@@ -114,7 +115,10 @@ class FeatureManager(
HideFriendFeedEntry(),
RequerySqlite(),
RefreshFriendSuggestions(),
LocalPinnedMessages(),
BlockCalls(),
CallMetadataNotifier(),
ConversationSoundEffects(),
CallButtonsOverride(),
SnapPreview(),
BypassScreenshotDetection(),

View File

@@ -37,11 +37,32 @@ class ConfigurationOverride : Feature("Configuration Override") {
}.getOrNull()
val propertyOverrides = mutableMapOf<String, ConfigFilter>()
val loggedOverrides = mutableSetOf<String>()
fun overrideProperty(key: String, filter: (ConfigKeyInfo) -> Boolean, value: (ConfigKeyInfo) -> Any?, isAppExperiment: Boolean = false) {
propertyOverrides[key] = ConfigFilter(filter, value, isAppExperiment)
}
fun logPerformanceOverride(key: String, value: Any?) {
if (!key.contains("PRELOAD") &&
!key.contains("PERFORMANCE") &&
!key.contains("WARM") &&
!key.contains("PREFETCH") &&
!key.contains("LATENCY") &&
!key.contains("ANALYTICS") &&
!key.contains("THREAD_PRIORITY") &&
!key.contains("HD_MODE") &&
!key.contains("LENS") &&
!key.contains("THUMBNAIL")
) {
return
}
synchronized(loggedOverrides) {
if (!loggedOverrides.add(key)) return
}
context.log.info("Performance override applied: $key=$value", "PerformanceMode")
}
overrideProperty("STREAK_EXPIRATION_INFO", { context.config.userInterface.streakExpirationInfo.get() },
{ true })
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() },
@@ -84,6 +105,44 @@ class ConfigurationOverride : Feature("Configuration Override") {
{ true })
overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() },
{ true })
overrideProperty("PREVIEW_PRELOAD_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("BUFFERED_VIDEO_RECORDING_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("CAMERA_THREAD_PRIORITY", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("HD_MODE_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() == "max" },
{ true })
arrayOf(
"FEATURE_PRELOADER",
"USER_STORY_PRELOAD",
"STARTUP_LENS_ACTIVATOR",
"LENSES_PREVIEW_ACTIVATOR",
"THUMBNAIL_PRESENTER_ACTIVATOR",
"SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR",
"SERVER_PREFETCH",
"SERVER_PREFETCH_WITH_COF",
"DISCOVER_FEED_PERFORMANCE",
"DISCOVER_FEED_STORY_PREFETCH",
"DISCOVER_FEED_THUMBNAILS",
"LOGIN_PRELOAD",
"PREFETCH_REPO_SUBSCRIBE_ON_CPU",
"COMPUTE_FEED_CACHE_WITH_TTL",
"COMPUTE_FEED_NETWORK_WITH_CACHE",
"OPERA_WARMUP",
"REFACTORED_WITH_WARMUP_LENS",
"SHOW_PREFETCH",
).forEach { key ->
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() != null }, { true })
}
overrideProperty("LOAD_LATENCY_TRACKER_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ false })
overrideProperty("ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ false })
overrideProperty("LOCK_SCREEN_ANALYTICS_ACTIVATOR", { context.config.global.performanceMode.profile.getNullable() != null },
{ false })
overrideProperty("REDUCE_MY_PROFILE_UI_COMPLEXITY", { context.config.userInterface.mapFriendNameTags.get() },
{ true })
@@ -115,7 +174,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
propertyOverrides[propertyKey.name]?.let { (filter, value) ->
if (!filter(propertyKey)) return@let
param.setResult(value(propertyKey))
value(propertyKey).also {
logPerformanceOverride(propertyKey.name ?: return@also, it)
param.setResult(it)
}
}
}
@@ -135,7 +197,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
propertyOverrides[key]?.let { (filter, value) ->
val keyInfo = getConfigKeyInfo(enumData) ?: return@let
if (!filter(keyInfo)) return@let
setValue(value(keyInfo))
value(keyInfo).also {
logPerformanceOverride(key, it)
setValue(it)
}
}
}
@@ -151,7 +216,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
}
propertyOverrides[keyInfo.name]?.let { (filter, value, isAppExperiment) ->
if (isAppExperiment != true || !filter(keyInfo)) return@let
param.setResult(value(keyInfo))
value(keyInfo).also {
logPerformanceOverride(keyInfo.name ?: return@also, it)
param.setResult(it)
}
}
}
@@ -174,7 +242,10 @@ class ConfigurationOverride : Feature("Configuration Override") {
}
val propertyOverride = propertyOverrides[keyInfo.name] ?: return@hook
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let { param.setResult(it) }
propertyOverride.isAppExperiment.takeIf { propertyOverride.filter(keyInfo) }?.let {
logPerformanceOverride(keyInfo.name ?: return@let, it)
param.setResult(it)
}
}
}
}.onFailure {

View File

@@ -12,7 +12,6 @@ import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
@@ -30,14 +29,11 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -45,8 +41,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -125,11 +119,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
var lastSeenMapParams: ParamMap? = null
private set
private val storyPreviewCache = mutableMapOf<String, MutableMap<Int, Bitmap>>()
@Volatile
private var pendingBatchDownloadIndices: MutableList<Int>? = null
@Volatile
private var batchForceAllowDuplicate: Boolean = false
private val translations by lazy {
context.translation.getCategory("download_processor")
}
@@ -267,68 +256,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val tr = context.translation.getCategory("download_processor.story_snap_dialog")
val cancelStr = context.translation["button.cancel"]
val downloadStr = context.translation["button.download"]
val previewCacheKey = buildString {
append(paramMap["STORY_ID"]?.toString() ?: "story")
append("|")
append(paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: "user")
append("|")
append(totalCount)
}
context.runOnUiThread {
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
val selected = remember { mutableStateListOf<Int>().apply { add(currentIndex) } }
val previewBitmaps = remember { mutableStateMapOf<Int, Bitmap?>() }
val previewLoading = remember { mutableStateMapOf<Int, Boolean>() }
LaunchedEffect(Unit) {
if (!selected.contains(currentIndex)) selected.add(currentIndex)
mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri?.let { currentUri ->
previewLoading[currentIndex] = true
previewBitmaps[currentIndex] = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(currentUri) }
previewLoading[currentIndex] = false
}
synchronized(storyPreviewCache) {
storyPreviewCache[previewCacheKey]?.forEach { (index, bitmap) ->
previewBitmaps[index] = bitmap
}
}
}
LaunchedEffect(previewCacheKey) {
val overlay = context.feature(OperaStoryOverlay::class)
val cachedIndices = synchronized(storyPreviewCache) {
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }.keys.toSet()
}
val indicesToScan = (0 until totalCount).filter { it != currentIndex && it !in cachedIndices }
try {
for (targetIndex in indicesToScan) {
val jumped = withContext(Dispatchers.Main) {
overlay.requestJumpToSnap(targetIndex, totalCount)
}
if (!jumped) continue
val reached = waitForStoryIndex(targetIndex)
if (!reached) continue
val uri = lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri ?: continue
previewLoading[targetIndex] = true
val bitmap = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(uri) }
previewLoading[targetIndex] = false
if (bitmap != null) {
previewBitmaps[targetIndex] = bitmap
synchronized(storyPreviewCache) {
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }[targetIndex] = bitmap
}
}
}
} finally {
withContext(Dispatchers.Main) {
overlay.requestJumpToSnap(currentIndex, totalCount)
}
}
}
PurrfectGlassCard(
title = tr["title"],
@@ -362,35 +293,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
},
colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)
)
Box(
modifier = Modifier
.size(54.dp)
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
val rowBitmap = previewBitmaps[index]
val rowLoading = previewLoading[index] == true
when {
rowBitmap != null -> Image(
bitmap = rowBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.size(54.dp)
.background(Color.Transparent, RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
)
rowLoading -> CircularProgressIndicator(
color = PurrfectOverlayPalette.glowPrimary,
modifier = Modifier.size(22.dp),
strokeWidth = 2.dp
)
else -> Icon(
imageVector = Icons.Outlined.Image,
contentDescription = null,
tint = PurrfectOverlayPalette.textSecondary
)
}
}
Text(
label,
style = MaterialTheme.typography.bodyMedium,
@@ -435,10 +337,15 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
Button(
onClick = {
if (selected.isNotEmpty()) {
startBatchDownload(selected.sorted().toMutableList(), allowDuplicate)
alertDialog.dismiss()
if (!selected.contains(currentIndex)) return@Button
context.executeAsync {
runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) }
.onFailure {
context.log.error("Story download failed", it)
context.shortToast(translations["failed_generic_toast"])
}
}
alertDialog.dismiss()
},
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(14.dp),
@@ -457,124 +364,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private suspend fun waitForStoryIndex(targetIndex: Int, timeoutMs: Long = 3000L): Boolean {
val startedAt = System.currentTimeMillis()
while (System.currentTimeMillis() - startedAt < timeoutMs) {
if (lastSeenMapParams?.getStorySnapIndex() == targetIndex) return true
kotlinx.coroutines.delay(60L)
}
return false
}
private fun loadStoryPreviewBitmap(uriString: String): Bitmap? {
return runCatching {
val uri = Uri.parse(uriString)
when (uri.scheme?.lowercase()) {
"content" -> context.androidContext.contentResolver.openInputStream(uri)?.use(BitmapFactory::decodeStream)
"file", null -> BitmapFactory.decodeFile(uri.path)
"http", "https" -> {
runCatching {
OkHttpClient().newCall(Request.Builder().url(uriString).build()).execute().use { response ->
response.body?.byteStream()?.use { stream -> BitmapFactory.decodeStream(stream) }
}
}.getOrNull() ?: run {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(uriString, emptyMap())
retriever.frameAtTime
} finally {
runCatching { retriever.release() }
}
}
}
else -> null
} ?: run {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(context.androidContext, uri)
retriever.frameAtTime
} finally {
runCatching { retriever.release() }
}
}
}.getOrNull()
}
private fun startBatchDownload(indices: MutableList<Int>, allowDuplicate: Boolean) {
if (indices.isEmpty()) return
val paramMap = lastSeenMapParams ?: return
val mediaInfoMap = lastSeenMediaInfoMap ?: return
pendingBatchDownloadIndices = indices
batchForceAllowDuplicate = allowDuplicate
val currentIndex = paramMap.getStorySnapIndex() ?: 0
val targetIndex = indices.first()
val totalCount = paramMap.getStorySnapTotal()
if (currentIndex == targetIndex) {
processNextBatchDownload(paramMap, mediaInfoMap)
} else {
val jumped = context.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount)
if (!jumped) {
pendingBatchDownloadIndices = null
context.shortToast(translations["batch_download_jump_failed_toast"])
}
}
}
private fun downloadSingleSnap(paramMap: ParamMap, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
context.executeAsync {
runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, batchForceAllowDuplicate) }
.onFailure {
context.log.error("Batch download failed", it)
context.shortToast(translations["failed_generic_toast"])
}
}
}
private fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
val queue = pendingBatchDownloadIndices ?: return
if (queue.isEmpty()) {
flushPendingMergeAndComplete()
return
}
val currentIndex = paramMap.getStorySnapIndex() ?: -1
if (currentIndex != queue.first()) return
queue.removeAt(0)
downloadSingleSnap(paramMap, mediaInfoMap)
if (queue.isEmpty()) {
flushPendingMergeAndComplete()
} else {
val totalCount = paramMap.getStorySnapTotal()
context.runOnUiThread {
fun tryJump(retryCount: Int = 0) {
val delayMs = if (retryCount == 0) 120L else 220L
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
val jumped = runCatching {
context.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount)
}.getOrNull() == true
if (!jumped && retryCount < 1) {
tryJump(retryCount + 1)
} else if (!jumped) {
pendingBatchDownloadIndices = null
context.shortToast(translations["batch_download_jump_failed_toast"])
}
}, delayMs)
}
tryJump()
}
}
}
private fun flushPendingMergeAndComplete() {
pendingBatchDownloadIndices = null
context.shortToast(translations["batch_download_complete_toast"])
}
fun showLastOperaDebugMediaInfo() {
if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return
@@ -1063,15 +852,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
lastSeenMapParams = mediaParamMap
lastSeenMediaInfoMap = mediaInfoMap
if (pendingBatchDownloadIndices != null) {
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
if (pendingBatchDownloadIndices != null) {
processNextBatchDownload(mediaParamMap, mediaInfoMap)
}
}, 80L)
return@onOperaViewStateCallback
}
if (!shouldAutoDownload) {
return@onOperaViewStateCallback
}

View File

@@ -15,193 +15,358 @@ data class DeviceInfo(
val host: String
)
data class DeviceBuildProfile(
val androidRelease: String,
val display: String,
val buildId: String,
val incremental: String,
val host: String,
val bootloader: String? = null
)
data class DeviceCapabilityProfile(
val supportedAbis: List<String>,
val supported32BitAbis: List<String>,
val supported64BitAbis: List<String>,
val phoneCount: Int,
val isHearingAidCompatibilitySupported: Boolean,
val isTtySupported: Boolean,
val isWorldPhone: Boolean,
val isSmsCapable: Boolean,
val isVoiceCapable: Boolean,
val phoneType: Int,
val phoneTypeString: String
)
data class DeviceTemplate(
val marketingName: String,
val deviceInfo: DeviceInfo,
val builds: List<DeviceBuildProfile>,
val capabilities: DeviceCapabilityProfile
)
object DeviceSpoofer {
private val defaultCapabilities = DeviceCapabilityProfile(
supportedAbis = listOf("arm64-v8a", "armeabi-v7a", "armeabi"),
supported32BitAbis = listOf("armeabi-v7a", "armeabi"),
supported64BitAbis = listOf("arm64-v8a"),
phoneCount = 2,
isHearingAidCompatibilitySupported = true,
isTtySupported = false,
isWorldPhone = true,
isSmsCapable = true,
isVoiceCapable = true,
phoneType = 1,
phoneTypeString = "PHONE_TYPE_GSM"
)
private val singleSimCapabilities = defaultCapabilities.copy(phoneCount = 1)
private val devices = mapOf(
"Pixel 8 Pro" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 8 Pro",
brand = "google",
device = "husky",
product = "husky",
hardware = "husky",
board = "husky",
bootloader = "husky-1.0-11003666",
display = "UQ1A.231205.015",
host = "abfarm-release-rbe-64-00163"
"Pixel 8 Pro" to DeviceTemplate(
marketingName = "Pixel 8 Pro",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 8 Pro",
brand = "google",
device = "husky",
product = "husky",
hardware = "husky",
board = "husky",
bootloader = "husky-1.0-11003666",
display = "UQ1A.231205.015",
host = "abfarm-release-rbe-64-00163"
),
builds = listOf(
DeviceBuildProfile("14", "UQ1A.231205.015", "UQ1A.231205.015", "11003666", "abfarm-release-rbe-64-00163", "husky-1.0-11003666"),
DeviceBuildProfile("15", "AP4A.250205.002", "AP4A.250205.002", "12141234", "abfarm-release-rbe-64-00171", "husky-1.0-12141234")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 9 Pro XL" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 9 Pro XL",
brand = "google",
device = "komodo",
product = "komodo",
hardware = "komodo",
board = "komodo",
bootloader = "komodo-1.0-12110753",
display = "AP3A.241105.008",
host = "abfarm-release-rbe-64-00163"
"Pixel 9 Pro XL" to DeviceTemplate(
marketingName = "Pixel 9 Pro XL",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 9 Pro XL",
brand = "google",
device = "komodo",
product = "komodo",
hardware = "komodo",
board = "komodo",
bootloader = "komodo-1.0-12110753",
display = "AP3A.241105.008",
host = "abfarm-release-rbe-64-00163"
),
builds = listOf(
DeviceBuildProfile("14", "AP3A.241105.008", "AP3A.241105.008", "12110753", "abfarm-release-rbe-64-00163", "komodo-1.0-12110753"),
DeviceBuildProfile("15", "BP1A.250105.006", "BP1A.250105.006", "13120567", "abfarm-release-rbe-65-00088", "komodo-1.0-13120567")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 10" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 10",
brand = "google",
device = "frankel",
product = "frankel",
hardware = "tensor_g5",
board = "frankel",
bootloader = "frankel-1.0-12345678",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00200"
"Pixel 10" to DeviceTemplate(
marketingName = "Pixel 10",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 10",
brand = "google",
device = "frankel",
product = "frankel",
hardware = "tensor_g5",
board = "frankel",
bootloader = "frankel-1.0-12345678",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00200"
),
builds = listOf(
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345678", "abfarm-release-rbe-65-00200", "frankel-1.0-12345678")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 10 Pro" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro",
brand = "google",
device = "blazer",
product = "blazer",
hardware = "tensor_g5",
board = "blazer",
bootloader = "blazer-1.0-12345679",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00201"
"Pixel 10 Pro" to DeviceTemplate(
marketingName = "Pixel 10 Pro",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro",
brand = "google",
device = "blazer",
product = "blazer",
hardware = "tensor_g5",
board = "blazer",
bootloader = "blazer-1.0-12345679",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00201"
),
builds = listOf(
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345679", "abfarm-release-rbe-65-00201", "blazer-1.0-12345679")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 10 Pro XL" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro XL",
brand = "google",
device = "mustang",
product = "mustang",
hardware = "tensor_g5",
board = "mustang",
bootloader = "mustang-1.0-12345680",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00202"
"Pixel 10 Pro XL" to DeviceTemplate(
marketingName = "Pixel 10 Pro XL",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro XL",
brand = "google",
device = "mustang",
product = "mustang",
hardware = "tensor_g5",
board = "mustang",
bootloader = "mustang-1.0-12345680",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00202"
),
builds = listOf(
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345680", "abfarm-release-rbe-65-00202", "mustang-1.0-12345680")
),
capabilities = defaultCapabilities.copy(phoneCount = 1, isWorldPhone = false)
),
"Pixel 10 Pro Fold" to DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro Fold",
brand = "google",
device = "rango",
product = "rango",
hardware = "tensor_g5",
board = "rango",
bootloader = "rango-1.0-12345681",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00203"
"Pixel 10 Pro Fold" to DeviceTemplate(
marketingName = "Pixel 10 Pro Fold",
deviceInfo = DeviceInfo(
manufacturer = "Google",
model = "Pixel 10 Pro Fold",
brand = "google",
device = "rango",
product = "rango",
hardware = "tensor_g5",
board = "rango",
bootloader = "rango-1.0-12345681",
display = "BP1A.250105.002",
host = "abfarm-release-rbe-65-00203"
),
builds = listOf(
DeviceBuildProfile("15", "BP1A.250105.002", "BP1A.250105.002", "12345681", "abfarm-release-rbe-65-00203", "rango-1.0-12345681")
),
capabilities = singleSimCapabilities.copy(isWorldPhone = false)
),
"Galaxy S23 Ultra" to DeviceInfo(
manufacturer = "Samsung",
model = "SM-S918B",
brand = "samsung",
device = "dm3q",
product = "dm3qxx",
hardware = "qcom",
board = "kalama",
bootloader = "S918BXXU3BWJM",
display = "UP1A.231005.007.S918BXXU3BWJM",
host = "21DH7R2P"
"Galaxy S23 Ultra" to DeviceTemplate(
marketingName = "Galaxy S23 Ultra",
deviceInfo = DeviceInfo(
manufacturer = "Samsung",
model = "SM-S918B",
brand = "samsung",
device = "dm3q",
product = "dm3qxx",
hardware = "qcom",
board = "kalama",
bootloader = "S918BXXU3BWJM",
display = "UP1A.231005.007.S918BXXU3BWJM",
host = "21DH7R2P"
),
builds = listOf(
DeviceBuildProfile("14", "UP1A.231005.007.S918BXXU3BWJM", "UP1A.231005.007", "S918BXXU3BWJM", "21DH7R2P", "S918BXXU3BWJM"),
DeviceBuildProfile("15", "AP3A.240905.015.S918BXXU4CXA1", "AP3A.240905.015", "S918BXXU4CXA1", "21DH7R2P", "S918BXXU4CXA1")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"Galaxy S24 Ultra" to DeviceInfo(
manufacturer = "Samsung",
model = "SM-S928B",
brand = "samsung",
device = "e9q",
product = "e9qxx",
hardware = "qcom",
board = "pineapple",
bootloader = "S928BXXU1AXB5",
display = "UP1A.231005.007.S928BXXU1AXB5",
host = "21DH7R2P"
"Galaxy S24 Ultra" to DeviceTemplate(
marketingName = "Galaxy S24 Ultra",
deviceInfo = DeviceInfo(
manufacturer = "Samsung",
model = "SM-S928B",
brand = "samsung",
device = "e9q",
product = "e9qxx",
hardware = "qcom",
board = "pineapple",
bootloader = "S928BXXU1AXB5",
display = "UP1A.231005.007.S928BXXU1AXB5",
host = "21DH7R2P"
),
builds = listOf(
DeviceBuildProfile("14", "UP1A.231005.007.S928BXXU1AXB5", "UP1A.231005.007", "S928BXXU1AXB5", "21DH7R2P", "S928BXXU1AXB5"),
DeviceBuildProfile("15", "AP3A.240905.015.S928BXXU2BYD6", "AP3A.240905.015", "S928BXXU2BYD6", "21DH7R2P", "S928BXXU2BYD6")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"Galaxy S25 Ultra" to DeviceInfo(
manufacturer = "Samsung",
model = "SM-S938B",
brand = "samsung",
device = "e3q",
product = "e3qxx",
hardware = "qcom",
board = "s5e9945",
bootloader = "S938BXXU1AXL2",
display = "UP1A.231005.007.S938BXXU1AXL2",
host = "21DH7R2P"
"Galaxy S25 Ultra" to DeviceTemplate(
marketingName = "Galaxy S25 Ultra",
deviceInfo = DeviceInfo(
manufacturer = "Samsung",
model = "SM-S938B",
brand = "samsung",
device = "e3q",
product = "e3qxx",
hardware = "qcom",
board = "s5e9945",
bootloader = "S938BXXU1AXL2",
display = "UP1A.231005.007.S938BXXU1AXL2",
host = "21DH7R2P"
),
builds = listOf(
DeviceBuildProfile("15", "AP3A.241005.019.S938BXXU1AXL2", "AP3A.241005.019", "S938BXXU1AXL2", "21DH7R2P", "S938BXXU1AXL2")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"OnePlus 15" to DeviceInfo(
manufacturer = "OnePlus",
model = "CPH2651",
brand = "OnePlus",
device = "OP5929L1",
product = "OP5929L1_EEA",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "CPH2651_15.0.0.503(EX01)",
host = "ubuntu-build"
"OnePlus 15" to DeviceTemplate(
marketingName = "OnePlus 15",
deviceInfo = DeviceInfo(
manufacturer = "OnePlus",
model = "CPH2651",
brand = "OnePlus",
device = "OP5929L1",
product = "OP5929L1_EEA",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "CPH2651_15.0.0.503(EX01)",
host = "ubuntu-build"
),
builds = listOf(
DeviceBuildProfile("15", "CPH2651_15.0.0.503(EX01)", "CPH2651_15.0.0.503(EX01)", "15.0.0.503", "ubuntu-build"),
DeviceBuildProfile("15", "CPH2651_15.0.0.601(EX01)", "CPH2651_15.0.0.601(EX01)", "15.0.0.601", "ubuntu-build")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"OnePlus Open" to DeviceInfo(
manufacturer = "OnePlus",
model = "CPH2551",
brand = "OnePlus",
device = "OP594DL1",
product = "OP594DL1_EEA",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "CPH2551_14.0.0.600(EX01)",
host = "ubuntu-build"
"OnePlus Open" to DeviceTemplate(
marketingName = "OnePlus Open",
deviceInfo = DeviceInfo(
manufacturer = "OnePlus",
model = "CPH2551",
brand = "OnePlus",
device = "OP594DL1",
product = "OP594DL1_EEA",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "CPH2551_14.0.0.600(EX01)",
host = "ubuntu-build"
),
builds = listOf(
DeviceBuildProfile("14", "CPH2551_14.0.0.600(EX01)", "CPH2551_14.0.0.600(EX01)", "14.0.0.600", "ubuntu-build"),
DeviceBuildProfile("15", "CPH2551_15.0.0.305(EX01)", "CPH2551_15.0.0.305(EX01)", "15.0.0.305", "ubuntu-build")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"Xiaomi 15 Ultra" to DeviceInfo(
manufacturer = "Xiaomi",
model = "25010PN30G",
brand = "Xiaomi",
device = "xuanyuan",
product = "xuanyuan_global",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "VK.15.0.3.0.VNGMIXM",
host = "c3-miui-ota-bd164.bj"
"Xiaomi 15 Ultra" to DeviceTemplate(
marketingName = "Xiaomi 15 Ultra",
deviceInfo = DeviceInfo(
manufacturer = "Xiaomi",
model = "25010PN30G",
brand = "Xiaomi",
device = "xuanyuan",
product = "xuanyuan_global",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "VK.15.0.3.0.VNGMIXM",
host = "c3-miui-ota-bd164.bj"
),
builds = listOf(
DeviceBuildProfile("15", "VK.15.0.3.0.VNGMIXM", "VK.15.0.3.0.VNGMIXM", "15.0.3.0", "c3-miui-ota-bd164.bj"),
DeviceBuildProfile("15", "VK.15.0.6.0.VNGMIXM", "VK.15.0.6.0.VNGMIXM", "15.0.6.0", "c3-miui-ota-bd164.bj")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"OPPO Find X9 Pro" to DeviceInfo(
manufacturer = "OPPO",
model = "PHY110",
brand = "OPPO",
device = "OP595DL1",
product = "OP595DL1_EEA",
hardware = "mt6989",
board = "k6989v1_64",
bootloader = "unknown",
display = "PHY110_15.0.0.100(EX01)",
host = "ubuntu-build-server"
"OPPO Find X9 Pro" to DeviceTemplate(
marketingName = "OPPO Find X9 Pro",
deviceInfo = DeviceInfo(
manufacturer = "OPPO",
model = "PHY110",
brand = "OPPO",
device = "OP595DL1",
product = "OP595DL1_EEA",
hardware = "mt6989",
board = "k6989v1_64",
bootloader = "unknown",
display = "PHY110_15.0.0.100(EX01)",
host = "ubuntu-build-server"
),
builds = listOf(
DeviceBuildProfile("15", "PHY110_15.0.0.100(EX01)", "PHY110_15.0.0.100(EX01)", "15.0.0.100", "ubuntu-build-server"),
DeviceBuildProfile("15", "PHY110_15.0.0.202(EX01)", "PHY110_15.0.0.202(EX01)", "15.0.0.202", "ubuntu-build-server")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"vivo X100 Pro" to DeviceInfo(
manufacturer = "vivo",
model = "V2309A",
brand = "vivo",
device = "V2309A",
product = "PD2309",
hardware = "mt6989",
board = "k6989v1_64",
bootloader = "unknown",
display = "OP557L.PD2309.14.0.0.100",
host = "compiler-server"
"vivo X100 Pro" to DeviceTemplate(
marketingName = "vivo X100 Pro",
deviceInfo = DeviceInfo(
manufacturer = "vivo",
model = "V2309A",
brand = "vivo",
device = "V2309A",
product = "PD2309",
hardware = "mt6989",
board = "k6989v1_64",
bootloader = "unknown",
display = "PD2309F_EX_A_14.0.13.2.W30",
host = "compiler-server"
),
builds = listOf(
DeviceBuildProfile("14", "PD2309F_EX_A_14.0.13.2.W30", "PD2309F_EX_A_14.0.13.2.W30", "14.0.13.2", "compiler-server"),
DeviceBuildProfile("15", "PD2309F_EX_A_15.0.8.5.W30", "PD2309F_EX_A_15.0.8.5.W30", "15.0.8.5", "compiler-server")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
),
"realme GT 6" to DeviceInfo(
manufacturer = "realme",
model = "RMX3851",
brand = "realme",
device = "RMX3851",
product = "RMX3851_11_A.13",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "RMX3851_14.0.0.700(EX01)",
host = "ubuntu-server"
"realme GT 6" to DeviceTemplate(
marketingName = "realme GT 6",
deviceInfo = DeviceInfo(
manufacturer = "realme",
model = "RMX3851",
brand = "realme",
device = "RMX3851",
product = "RMX3851_11_A.13",
hardware = "qcom",
board = "taro",
bootloader = "unknown",
display = "RMX3851_14.0.0.700(EX01)",
host = "ubuntu-server"
),
builds = listOf(
DeviceBuildProfile("14", "RMX3851_14.0.0.700(EX01)", "RMX3851_14.0.0.700(EX01)", "14.0.0.700", "ubuntu-server"),
DeviceBuildProfile("15", "RMX3851_15.0.0.205(EX01)", "RMX3851_15.0.0.205(EX01)", "15.0.0.205", "ubuntu-server")
),
capabilities = defaultCapabilities.copy(phoneCount = 2)
)
)
fun getAvailableDevices(): List<String> = devices.keys.toList()
fun getDeviceInfo(modelName: String): DeviceInfo? {
return devices[modelName]?.deviceInfo
}
fun getDeviceTemplate(modelName: String): DeviceTemplate? {
return devices[modelName]
}
@@ -211,6 +376,10 @@ object DeviceSpoofer {
return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:$buildVersion/$id/$incremental:user/release-keys"
}
fun generateFingerprint(deviceInfo: DeviceInfo, buildProfile: DeviceBuildProfile): String {
return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:${buildProfile.androidRelease}/${buildProfile.buildId}/${buildProfile.incremental}:user/release-keys"
}
fun generateAndroidId(): String {
val random = SecureRandom()
val bytes = ByteArray(8)

View File

@@ -77,7 +77,6 @@ class MediaFilePicker : Feature("Media File Picker") {
private var bypassSplitOnce = false
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
private var cleanupItemHandler: ((String) -> Unit)? = null
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null

View File

@@ -0,0 +1,578 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import android.content.Context
import me.eternal.purrfectsnap.common.logger.AbstractLogger
import org.json.JSONArray
import org.json.JSONObject
import java.net.InetAddress
import java.security.SecureRandom
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
data class RandomizedDeviceProfile(
val schemaVersion: Int,
val profileId: String,
val deviceInfo: DeviceInfo,
val androidRelease: String,
val buildIncremental: String,
val buildDisplayId: String,
val buildFingerprint: String,
val buildHost: String,
val buildTime: Long,
val supportedAbis: List<String>,
val supported32BitAbis: List<String>,
val supported64BitAbis: List<String>,
val androidId: String,
val gsfId: String,
val advertisingId: String,
val wifiMacAddress: String,
val bluetoothMacAddress: String,
val ipAddress: String,
val wifiSsid: String,
val wifiRssi: Int,
val localeTag: String,
val countryIso: String,
val timeZoneId: String,
val timeZoneDisplayName: String,
val networkType: Int,
val networkOperator: String,
val networkOperatorName: String,
val networkCountryIso: String,
val simCountryIso: String,
val simOperator: String,
val simOperatorName: String,
val simState: Int,
val hasIccCard: Boolean,
val phoneCount: Int,
val isHearingAidCompatibilitySupported: Boolean,
val isTtySupported: Boolean,
val isWorldPhone: Boolean,
val isNetworkRoaming: Boolean,
val isSmsCapable: Boolean,
val isVoiceCapable: Boolean,
val phoneType: Int,
val phoneTypeString: String,
val mmsUaProfUrl: String,
val mmsUserAgent: String,
val dnsServers: List<String>,
val dnsSearchDomains: String,
val privateDnsServerName: String,
val privateDnsActive: Boolean,
val hasCaptivePortal: Boolean,
val secureStringSettings: Map<String, String>,
val secureIntSettings: Map<String, Int>,
val systemStringSettings: Map<String, String>,
val systemIntSettings: Map<String, Int>,
val globalStringSettings: Map<String, String>,
val globalIntSettings: Map<String, Int>
) {
fun locale(): Locale = Locale.forLanguageTag(localeTag)
fun timeZone(): TimeZone = TimeZone.getTimeZone(timeZoneId)
fun toJson(): JSONObject = JSONObject().apply {
put("schemaVersion", schemaVersion)
put("profileId", profileId)
put("deviceInfo", JSONObject().apply {
put("manufacturer", deviceInfo.manufacturer)
put("model", deviceInfo.model)
put("brand", deviceInfo.brand)
put("device", deviceInfo.device)
put("product", deviceInfo.product)
put("hardware", deviceInfo.hardware)
put("board", deviceInfo.board)
put("bootloader", deviceInfo.bootloader)
put("display", deviceInfo.display)
put("host", deviceInfo.host)
})
put("androidRelease", androidRelease)
put("buildIncremental", buildIncremental)
put("buildDisplayId", buildDisplayId)
put("buildFingerprint", buildFingerprint)
put("buildHost", buildHost)
put("buildTime", buildTime)
put("supportedAbis", JSONArray(supportedAbis))
put("supported32BitAbis", JSONArray(supported32BitAbis))
put("supported64BitAbis", JSONArray(supported64BitAbis))
put("androidId", androidId)
put("gsfId", gsfId)
put("advertisingId", advertisingId)
put("wifiMacAddress", wifiMacAddress)
put("bluetoothMacAddress", bluetoothMacAddress)
put("ipAddress", ipAddress)
put("wifiSsid", wifiSsid)
put("wifiRssi", wifiRssi)
put("localeTag", localeTag)
put("countryIso", countryIso)
put("timeZoneId", timeZoneId)
put("timeZoneDisplayName", timeZoneDisplayName)
put("networkType", networkType)
put("networkOperator", networkOperator)
put("networkOperatorName", networkOperatorName)
put("networkCountryIso", networkCountryIso)
put("simCountryIso", simCountryIso)
put("simOperator", simOperator)
put("simOperatorName", simOperatorName)
put("simState", simState)
put("hasIccCard", hasIccCard)
put("phoneCount", phoneCount)
put("isHearingAidCompatibilitySupported", isHearingAidCompatibilitySupported)
put("isTtySupported", isTtySupported)
put("isWorldPhone", isWorldPhone)
put("isNetworkRoaming", isNetworkRoaming)
put("isSmsCapable", isSmsCapable)
put("isVoiceCapable", isVoiceCapable)
put("phoneType", phoneType)
put("phoneTypeString", phoneTypeString)
put("mmsUaProfUrl", mmsUaProfUrl)
put("mmsUserAgent", mmsUserAgent)
put("dnsServers", JSONArray(dnsServers))
put("dnsSearchDomains", dnsSearchDomains)
put("privateDnsServerName", privateDnsServerName)
put("privateDnsActive", privateDnsActive)
put("hasCaptivePortal", hasCaptivePortal)
put("secureStringSettings", JSONObject(secureStringSettings))
put("secureIntSettings", JSONObject(secureIntSettings))
put("systemStringSettings", JSONObject(systemStringSettings))
put("systemIntSettings", JSONObject(systemIntSettings))
put("globalStringSettings", JSONObject(globalStringSettings))
put("globalIntSettings", JSONObject(globalIntSettings))
}
companion object {
fun fromJson(json: String): RandomizedDeviceProfile {
val root = JSONObject(json)
val deviceInfoJson = root.getJSONObject("deviceInfo")
return RandomizedDeviceProfile(
schemaVersion = root.getInt("schemaVersion"),
profileId = root.getString("profileId"),
deviceInfo = DeviceInfo(
manufacturer = deviceInfoJson.getString("manufacturer"),
model = deviceInfoJson.getString("model"),
brand = deviceInfoJson.getString("brand"),
device = deviceInfoJson.getString("device"),
product = deviceInfoJson.getString("product"),
hardware = deviceInfoJson.getString("hardware"),
board = deviceInfoJson.getString("board"),
bootloader = deviceInfoJson.getString("bootloader"),
display = deviceInfoJson.getString("display"),
host = deviceInfoJson.getString("host")
),
androidRelease = root.getString("androidRelease"),
buildIncremental = root.getString("buildIncremental"),
buildDisplayId = root.getString("buildDisplayId"),
buildFingerprint = root.getString("buildFingerprint"),
buildHost = root.getString("buildHost"),
buildTime = root.getLong("buildTime"),
supportedAbis = jsonArrayToStringList(root.getJSONArray("supportedAbis")),
supported32BitAbis = jsonArrayToStringList(root.getJSONArray("supported32BitAbis")),
supported64BitAbis = jsonArrayToStringList(root.getJSONArray("supported64BitAbis")),
androidId = root.getString("androidId"),
gsfId = root.getString("gsfId"),
advertisingId = root.getString("advertisingId"),
wifiMacAddress = root.getString("wifiMacAddress"),
bluetoothMacAddress = root.getString("bluetoothMacAddress"),
ipAddress = root.optString("ipAddress").ifBlank { defaultFallbackIpAddress() },
wifiSsid = root.getString("wifiSsid"),
wifiRssi = root.getInt("wifiRssi"),
localeTag = root.getString("localeTag"),
countryIso = root.getString("countryIso"),
timeZoneId = root.getString("timeZoneId"),
timeZoneDisplayName = root.getString("timeZoneDisplayName"),
networkType = root.getInt("networkType"),
networkOperator = root.getString("networkOperator"),
networkOperatorName = root.getString("networkOperatorName"),
networkCountryIso = root.getString("networkCountryIso"),
simCountryIso = root.getString("simCountryIso"),
simOperator = root.getString("simOperator"),
simOperatorName = root.getString("simOperatorName"),
simState = root.getInt("simState"),
hasIccCard = root.getBoolean("hasIccCard"),
phoneCount = root.getInt("phoneCount"),
isHearingAidCompatibilitySupported = root.getBoolean("isHearingAidCompatibilitySupported"),
isTtySupported = root.getBoolean("isTtySupported"),
isWorldPhone = root.getBoolean("isWorldPhone"),
isNetworkRoaming = root.getBoolean("isNetworkRoaming"),
isSmsCapable = root.getBoolean("isSmsCapable"),
isVoiceCapable = root.getBoolean("isVoiceCapable"),
phoneType = root.getInt("phoneType"),
phoneTypeString = root.getString("phoneTypeString"),
mmsUaProfUrl = root.getString("mmsUaProfUrl"),
mmsUserAgent = root.getString("mmsUserAgent"),
dnsServers = jsonArrayToStringList(root.getJSONArray("dnsServers")),
dnsSearchDomains = root.getString("dnsSearchDomains"),
privateDnsServerName = root.getString("privateDnsServerName"),
privateDnsActive = root.getBoolean("privateDnsActive"),
hasCaptivePortal = root.getBoolean("hasCaptivePortal"),
secureStringSettings = jsonObjectToStringMap(root.getJSONObject("secureStringSettings")),
secureIntSettings = jsonObjectToIntMap(root.getJSONObject("secureIntSettings")),
systemStringSettings = jsonObjectToStringMap(root.getJSONObject("systemStringSettings")),
systemIntSettings = jsonObjectToIntMap(root.getJSONObject("systemIntSettings")),
globalStringSettings = jsonObjectToStringMap(root.getJSONObject("globalStringSettings")),
globalIntSettings = jsonObjectToIntMap(root.getJSONObject("globalIntSettings"))
)
}
private fun jsonArrayToStringList(array: JSONArray): List<String> = buildList {
for (index in 0 until array.length()) {
add(array.getString(index))
}
}
private fun jsonObjectToStringMap(jsonObject: JSONObject): Map<String, String> {
return jsonObject.keys().asSequence().associateWith { jsonObject.getString(it) }
}
private fun jsonObjectToIntMap(jsonObject: JSONObject): Map<String, Int> {
return jsonObject.keys().asSequence().associateWith { jsonObject.getInt(it) }
}
private fun defaultFallbackIpAddress(): String = "23.42.18.101"
}
}
object RandomizedDeviceProfileStore {
private const val prefsName = "purrfectsnap_spoof"
private const val schemaVersion = 5
private const val profileKey = "randomized_device_profile"
private val random = SecureRandom()
fun getOrCreate(context: Context, logger: AbstractLogger, generationToken: String?): RandomizedDeviceProfile {
val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
val requestedToken = generationToken.orEmpty()
prefs.getString(profileKey, null)?.let { raw ->
runCatching {
RandomizedDeviceProfile.fromJson(raw)
}.onSuccess { profile ->
val storedToken = prefs.getString("${profileKey}_token", "") ?: ""
if (profile.schemaVersion == schemaVersion && storedToken == requestedToken) {
logger.info("Loaded randomized device profile ${profile.profileId} (${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model})")
return profile
}
}.onFailure {
logger.warn("Failed to parse saved randomized device profile, regenerating: ${it.message}")
}
}
val previousProfile = prefs.getString(profileKey, null)?.let { raw ->
runCatching { RandomizedDeviceProfile.fromJson(raw) }.getOrNull()
}
val profile = generateProfile(previousProfile)
prefs.edit()
.putString(profileKey, profile.toJson().toString())
.putString("${profileKey}_token", requestedToken)
.putString("android_id", profile.androidId)
.putString("advertising_id", profile.advertisingId)
.putString("bluetooth_address", profile.bluetoothMacAddress)
.putString("gsf_id", profile.gsfId)
.putString("random_device", profile.deviceInfo.model)
.putString("device_fingerprint", profile.buildFingerprint)
.apply()
logger.info(
"Generated randomized device profile ${profile.profileId}: " +
"${profile.deviceInfo.manufacturer} ${profile.deviceInfo.model}, " +
"androidId=${profile.androidId}, ip=${profile.ipAddress}, locale=${profile.localeTag}, tz=${profile.timeZoneId}, " +
"carrier=${profile.simOperatorName}"
)
return profile
}
private fun generateProfile(previousProfile: RandomizedDeviceProfile?): RandomizedDeviceProfile {
val eligibleDevices = DeviceSpoofer.getAvailableDevices().filter {
DeviceSpoofer.getDeviceTemplate(it)?.capabilities?.let { capabilities ->
capabilities.isSmsCapable && capabilities.isVoiceCapable && capabilities.isWorldPhone
} == true
}.ifEmpty { DeviceSpoofer.getAvailableDevices() }
val deviceCandidates = eligibleDevices.filterNot {
previousProfile != null &&
DeviceSpoofer.getDeviceTemplate(it)?.deviceInfo?.model == previousProfile.deviceInfo.model
}.ifEmpty { eligibleDevices }
val deviceName = pick(deviceCandidates)
val deviceTemplate = DeviceSpoofer.getDeviceTemplate(deviceName) ?: error("Missing device template for $deviceName")
val regionCandidates = regionProfiles.filterNot {
previousProfile != null &&
it.localeTag == previousProfile.localeTag &&
it.simOperatorName == previousProfile.simOperatorName
}.ifEmpty { regionProfiles }
val region = pick(regionCandidates)
val buildCandidates = deviceTemplate.builds.filter { it.androidRelease in region.androidReleaseOptions }
.ifEmpty { deviceTemplate.builds }
val buildProfile = pick(buildCandidates)
val deviceInfo = deviceTemplate.deviceInfo.copy(
display = buildProfile.display,
host = buildProfile.host,
bootloader = buildProfile.bootloader ?: deviceTemplate.deviceInfo.bootloader
)
val androidRelease = buildProfile.androidRelease
val buildIncremental = buildProfile.incremental
val buildDisplayId = buildProfile.display
val buildFingerprint = DeviceSpoofer.generateFingerprint(deviceInfo, buildProfile)
val buildHost = buildProfile.host
val buildTime = System.currentTimeMillis() - randomLong(45L, 220L) * 24L * 60L * 60L * 1000L
val wifiMac = randomMacAddress()
val bluetoothMac = randomMacAddress()
val ipAddress = region.randomPublicIpAddress()
val locale = Locale.forLanguageTag(region.localeTag)
val timeZone = TimeZone.getTimeZone(region.timeZoneId)
val capabilities = deviceTemplate.capabilities
val secureStringSettings = mapOf(
"accessibility_enabled" to "0",
"speak_password" to "0",
"allowed_geolocation_origins" to "",
"install_non_market_apps" to "0",
"device_provisioned" to "1",
"enabled_notification_listeners" to ""
)
val secureIntSettings = mapOf(
"input_method_selector_visibility" to 0,
"accessibility_display_inversion_enabled" to 0,
"enabled_accessibility_services" to 0,
"skip_first_use_hints" to 0,
"tts_default_synth" to 0
)
val systemStringSettings = mapOf(
"dtmf_tone_type" to "normal",
"mode_ringer_streams_affected" to "166",
"mute_streams_affected" to "46",
"show_password" to "1",
"user_rotation" to "0"
)
val systemIntSettings = mapOf(
"bluetooth_discoverability" to 0,
"bluetooth_discoverability_timeout" to 120,
"date_format" to 0,
"end_button_behavior" to 2
)
val globalStringSettings = mapOf(
"adb_enabled" to "0",
"auto_time" to "1",
"auto_time_zone" to "1",
"development_settings_enabled" to "0",
"stay_on_while_plugged_in" to "0",
"usb_mass_storage_enabled" to "0",
"wifi_networks_available_notification_on" to "0",
"data_roaming" to "1"
)
val globalIntSettings = mapOf(
"always_finish_activities" to 0,
"animator_duration_scale" to 1,
"http_proxy" to 0,
"network_preference" to 1,
"transition_animation_scale" to 1,
"use_google_mail" to 1,
"wait_for_debugger" to 0
)
return RandomizedDeviceProfile(
schemaVersion = schemaVersion,
profileId = UUID.randomUUID().toString().substring(0, 8),
deviceInfo = deviceInfo,
androidRelease = androidRelease,
buildIncremental = buildIncremental,
buildDisplayId = buildDisplayId,
buildFingerprint = buildFingerprint,
buildHost = buildHost,
buildTime = buildTime,
supportedAbis = capabilities.supportedAbis,
supported32BitAbis = capabilities.supported32BitAbis,
supported64BitAbis = capabilities.supported64BitAbis,
androidId = randomHex(16),
gsfId = randomHex(16),
advertisingId = UUID.randomUUID().toString(),
wifiMacAddress = wifiMac,
bluetoothMacAddress = bluetoothMac,
ipAddress = ipAddress,
wifiSsid = region.randomWifiSsid(),
wifiRssi = randomInt(-72, -36),
localeTag = locale.toLanguageTag(),
countryIso = region.countryIso,
timeZoneId = region.timeZoneId,
timeZoneDisplayName = timeZone.getDisplayName(false, TimeZone.SHORT, locale),
networkType = 13,
networkOperator = region.networkOperator,
networkOperatorName = region.networkOperatorName,
networkCountryIso = region.countryIso.lowercase(Locale.US),
simCountryIso = region.countryIso.lowercase(Locale.US),
simOperator = region.simOperator,
simOperatorName = region.simOperatorName,
simState = 5,
hasIccCard = true,
phoneCount = capabilities.phoneCount,
isHearingAidCompatibilitySupported = capabilities.isHearingAidCompatibilitySupported,
isTtySupported = capabilities.isTtySupported,
isWorldPhone = capabilities.isWorldPhone,
isNetworkRoaming = false,
isSmsCapable = capabilities.isSmsCapable,
isVoiceCapable = capabilities.isVoiceCapable,
phoneType = capabilities.phoneType,
phoneTypeString = capabilities.phoneTypeString,
mmsUaProfUrl = "",
mmsUserAgent = region.mmsUserAgent(deviceTemplate.marketingName, androidRelease),
dnsServers = region.dnsServers.sortedBy { random.nextInt() }.take(2),
dnsSearchDomains = region.dnsSearchDomains,
privateDnsServerName = region.privateDnsServerName,
privateDnsActive = true,
hasCaptivePortal = false,
secureStringSettings = secureStringSettings,
secureIntSettings = secureIntSettings,
systemStringSettings = systemStringSettings,
systemIntSettings = systemIntSettings,
globalStringSettings = globalStringSettings,
globalIntSettings = globalIntSettings
)
}
private fun randomHex(length: Int): String {
val chars = CharArray(length)
val alphabet = "0123456789abcdef"
for (index in chars.indices) {
chars[index] = alphabet[random.nextInt(alphabet.length)]
}
return String(chars)
}
private fun randomDigits(length: Int): String {
val chars = CharArray(length)
for (index in chars.indices) {
chars[index] = ('0'.code + random.nextInt(10)).toChar()
}
return String(chars)
}
private fun randomMacAddress(): String {
val bytes = ByteArray(6)
random.nextBytes(bytes)
bytes[0] = (bytes[0].toInt() and 0xFE or 0x02).toByte()
return bytes.joinToString(":") { "%02x".format(it.toInt() and 0xFF) }
}
private fun randomPublicIpv4(prefixes: List<Int>? = null): String {
val firstOctet = prefixes?.takeIf { it.isNotEmpty() }?.let { pick(it) } ?: run {
generateSequence { randomInt(1, 224) }
.first { candidate ->
candidate != 10 &&
candidate != 127 &&
candidate != 169 &&
candidate != 172 &&
candidate != 192
}
}
val secondOctet = randomInt(1, 255)
val thirdOctet = randomInt(1, 255)
val fourthOctet = randomInt(2, 255)
val candidate = "$firstOctet.$secondOctet.$thirdOctet.$fourthOctet"
return runCatching { InetAddress.getByName(candidate).hostAddress }.getOrDefault(candidate)
}
private fun randomInt(minInclusive: Int, maxExclusive: Int): Int {
require(maxExclusive > minInclusive)
return minInclusive + random.nextInt(maxExclusive - minInclusive)
}
private fun randomLong(minInclusive: Long, maxExclusive: Long): Long {
require(maxExclusive > minInclusive)
val bound = maxExclusive - minInclusive
var bits: Long
var candidate: Long
do {
bits = random.nextLong() ushr 1
candidate = bits % bound
} while (bits - candidate + (bound - 1) < 0L)
return minInclusive + candidate
}
private fun <T> pick(values: List<T>): T = values[random.nextInt(values.size)]
private data class RegionProfile(
val localeTag: String,
val countryIso: String,
val timeZoneId: String,
val networkOperator: String,
val networkOperatorName: String,
val simOperator: String,
val simOperatorName: String,
val dnsServers: List<String>,
val dnsSearchDomains: String,
val privateDnsServerName: String,
val timeFormat: String,
val androidReleaseOptions: List<String>,
val wifiPrefixes: List<String>,
val ipPrefixes: List<Int>
) {
fun randomWifiSsid(): String = "${pick(wifiPrefixes)}-${randomDigits(4)}"
fun randomPublicIpAddress(): String = randomPublicIpv4(ipPrefixes)
fun mmsUserAgent(model: String, androidRelease: String): String {
return "$model/$androidRelease"
}
}
private val regionProfiles = listOf(
RegionProfile(
localeTag = "en-US",
countryIso = "US",
timeZoneId = "America/New_York",
networkOperator = "310260",
networkOperatorName = "T-Mobile",
simOperator = "310260",
simOperatorName = "T-Mobile",
dnsServers = listOf("8.8.8.8", "8.8.4.4", "1.1.1.1"),
dnsSearchDomains = "hsd1.ny.comcast.net",
privateDnsServerName = "dns.google",
timeFormat = "12",
androidReleaseOptions = listOf("14", "15"),
wifiPrefixes = listOf("TP-Link", "NETGEAR", "XFINITY", "HomeWiFi"),
ipPrefixes = listOf(23, 24, 45, 47, 66, 67, 68, 69, 72, 73, 98, 104, 107, 108, 162, 184, 198, 199)
),
RegionProfile(
localeTag = "en-GB",
countryIso = "GB",
timeZoneId = "Europe/London",
networkOperator = "23430",
networkOperatorName = "EE",
simOperator = "23430",
simOperatorName = "EE",
dnsServers = listOf("1.1.1.1", "1.0.0.1", "8.8.8.8"),
dnsSearchDomains = "bb.sky.com",
privateDnsServerName = "one.one.one.one",
timeFormat = "24",
androidReleaseOptions = listOf("14", "15"),
wifiPrefixes = listOf("Sky", "BT-Hub", "VirginMedia", "Linksys"),
ipPrefixes = listOf(51, 62, 77, 81, 86, 87, 88, 89, 90, 91, 92, 109, 141, 176, 185, 188)
),
RegionProfile(
localeTag = "de-DE",
countryIso = "DE",
timeZoneId = "Europe/Berlin",
networkOperator = "26202",
networkOperatorName = "Vodafone DE",
simOperator = "26202",
simOperatorName = "Vodafone DE",
dnsServers = listOf("9.9.9.9", "149.112.112.112", "1.1.1.1"),
dnsSearchDomains = "fritz.box",
privateDnsServerName = "dns.quad9.net",
timeFormat = "24",
androidReleaseOptions = listOf("14", "15"),
wifiPrefixes = listOf("FRITZBox", "Vodafone", "Telekom", "WLAN"),
ipPrefixes = listOf(2, 5, 31, 37, 46, 79, 80, 84, 85, 87, 91, 93, 95, 109, 134, 176, 178, 188)
),
RegionProfile(
localeTag = "en-IN",
countryIso = "IN",
timeZoneId = "Asia/Kolkata",
networkOperator = "405874",
networkOperatorName = "Jio",
simOperator = "405874",
simOperatorName = "Jio",
dnsServers = listOf("1.1.1.1", "8.8.8.8", "9.9.9.9"),
dnsSearchDomains = "airtelbroadband.in",
privateDnsServerName = "dns.google",
timeFormat = "12",
androidReleaseOptions = listOf("14", "15"),
wifiPrefixes = listOf("JioFiber", "Airtel", "ACTFibernet", "HomeNet"),
ipPrefixes = listOf(14, 27, 42, 49, 59, 61, 101, 103, 106, 117, 122, 125, 157, 182)
)
)
}

View File

@@ -7,7 +7,7 @@ import me.eternal.purrfectsnap.core.features.Feature
class DisableMetrics : Feature("DisableMetrics") {
override fun init() {
if (!context.config.global.disableMetrics.get()) return
if (!context.config.global.disableMetrics.get() && context.config.global.performanceMode.profile.getNullable() == null) return
context.event.subscribe(NetworkApiRequestEvent::class) { param ->
val url = param.url

View File

@@ -0,0 +1,274 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.os.Build
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import java.nio.ByteBuffer
import java.text.DateFormat
import java.util.Date
import java.util.LinkedHashSet
import java.util.Locale
class CallMetadataNotifier : Feature("Call Metadata Notifier") {
private data class CapturedCallMetadata(
var callUuid: String? = null,
var attemptId: String? = null,
var media: String? = null,
var isGroup: String? = null,
var scopeId: String? = null,
var ipv4Address: String? = null,
var startTimestamp: Long? = null,
var endedTimestamp: Long? = null,
val messageTypes: LinkedHashSet<String> = linkedSetOf(),
val rawPayloads: LinkedHashSet<String> = linkedSetOf()
) {
fun reset() {
callUuid = null
attemptId = null
media = null
isGroup = null
scopeId = null
ipv4Address = null
startTimestamp = null
endedTimestamp = null
messageTypes.clear()
rawPayloads.clear()
}
fun hasData(): Boolean {
return callUuid != null ||
attemptId != null ||
media != null ||
isGroup != null ||
scopeId != null ||
ipv4Address != null ||
messageTypes.isNotEmpty() ||
rawPayloads.isNotEmpty()
}
}
private val notificationChannelId = "call_metadata_notifier"
private val metadata = CapturedCallMetadata()
private var wasInCall = false
private val translation by lazy { context.translation.getCategory("call_metadata_notifier") }
private val notificationManager by lazy {
context.androidContext.getSystemService(NotificationManager::class.java).apply {
createNotificationChannel(
NotificationChannel(
notificationChannelId,
translation["notification_channel_name"],
NotificationManager.IMPORTANCE_HIGH
)
)
}
}
private fun isAscii(bytes: ByteArray): Boolean {
return bytes.all { it in 0x20..0x7E || it == 0x0A.toByte() || it == 0x0D.toByte() }
}
private fun collectStrings(reader: ProtoReader, depth: Int = 0, output: MutableList<String>) {
if (depth > 5) return
reader.eachBuffer { _, buffer ->
if (buffer.isEmpty()) return@eachBuffer
if (isAscii(buffer)) {
output.add(buffer.toString(Charsets.UTF_8))
}
runCatching { ProtoReader(buffer) }.getOrNull()?.let {
collectStrings(it, depth + 1, output)
}
}
}
private fun parseObjectPayload(payload: String): Map<String, String> {
val regex = Regex("\"([^\"]+)\"\\s*:\\s*(\"[^\"]*\"|true|false|-?\\d+(?:\\.\\d+)?)")
return regex.findAll(payload).associate { match ->
val key = match.groupValues[1]
val rawValue = match.groupValues[2]
key to rawValue.trim('"')
}
}
@Synchronized
private fun updateFromPayload(payload: String) {
if (!payload.startsWith("{") || !payload.endsWith("}")) return
val parsed = parseObjectPayload(payload)
if (parsed.isEmpty()) return
parsed["callUuid"]?.let { newCallUuid ->
if (metadata.callUuid != null && metadata.callUuid != newCallUuid && metadata.hasData()) {
notifyCallEnded("new_call_boundary")
}
metadata.callUuid = newCallUuid
}
parsed["attemptId"]?.let { metadata.attemptId = it }
parsed["media"]?.let { metadata.media = it }
parsed["isGroup"]?.let { metadata.isGroup = it }
parsed["scopeId"]?.let { metadata.scopeId = it }
parsed["ipv4Address"]?.let { metadata.ipv4Address = it }
parsed["messageType"]?.let { metadata.messageTypes.add(it) }
parsed["callAction"]?.let {
metadata.messageTypes.add("callAction:$it")
if (it.equals("START", ignoreCase = true) && metadata.startTimestamp == null) {
metadata.startTimestamp = System.currentTimeMillis()
wasInCall = true
}
if (it.equals("END", ignoreCase = true) || it.equals("HANGUP", ignoreCase = true)) {
handleCallEnded("call_action:$it")
}
}
metadata.rawPayloads.add(payload)
}
@Synchronized
private fun handleVolatileEvent(reader: ProtoReader) {
val payloads = mutableListOf<String>()
collectStrings(reader, output = payloads)
payloads.forEach { payload ->
updateFromPayload(payload)
val normalized = payload.lowercase(Locale.ROOT)
if ("caller_hangup" in normalized || "\"messagetype\":\"call_end\"" in normalized) {
handleCallEnded("volatile_end")
}
}
}
@Synchronized
private fun handleCallStarted(reason: String) {
if (!wasInCall) {
wasInCall = true
if (metadata.startTimestamp == null) {
metadata.startTimestamp = System.currentTimeMillis()
}
}
metadata.messageTypes.add("state:$reason")
}
@Synchronized
private fun handleCallEnded(reason: String) {
val shouldNotify = wasInCall || metadata.hasData()
wasInCall = false
if (!shouldNotify) return
notifyCallEnded(reason)
}
@Synchronized
private fun notifyCallEnded(reason: String) {
if (!metadata.hasData()) return
metadata.endedTimestamp = System.currentTimeMillis()
val lines = buildList {
metadata.ipv4Address?.let { add("IP: $it") }
metadata.scopeId?.let { add("Scope ID: $it") }
metadata.callUuid?.let { add("Call UUID: $it") }
metadata.attemptId?.let { add("Attempt ID: $it") }
metadata.media?.let { add("Media: $it") }
metadata.isGroup?.let { add("Group Call: $it") }
if (metadata.startTimestamp != null) {
add("Started: ${DateFormat.getDateTimeInstance().format(Date(metadata.startTimestamp!!))}")
}
if (metadata.endedTimestamp != null) {
add("Ended: ${DateFormat.getDateTimeInstance().format(Date(metadata.endedTimestamp!!))}")
}
if (metadata.messageTypes.isNotEmpty()) {
add("Events: ${metadata.messageTypes.joinToString(", ")}")
}
add("Reason: $reason")
}
val notification = Notification.Builder(context.androidContext, notificationChannelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(translation["notification_title"])
.setContentText(lines.firstOrNull() ?: translation["notification_empty"])
.setStyle(Notification.BigTextStyle().bigText(lines.joinToString("\n")))
.setAutoCancel(true)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
.build()
notificationManager.notify((metadata.callUuid ?: metadata.attemptId ?: reason).hashCode(), notification)
metadata.reset()
wasInCall = false
}
override fun init() {
if (!context.config.messaging.callMetadataNotifier.get()) return
runCatching {
findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").hook("onReceive", HookStage.BEFORE) { param ->
val buffer = param.argNullable<ByteBuffer>(0) ?: return@hook
val duplicate = buffer.duplicate().apply { position(0) }
val bytes = ByteArray(duplicate.limit())
duplicate.get(bytes)
val reader = ProtoReader(bytes)
if (reader.getString(1, 1) != "volatile") return@hook
val eventData = reader.followPath(1, 2) ?: return@hook
handleVolatileEvent(eventData)
}
}
runCatching {
findClass("com.google.firebase.messaging.FirebaseMessagingService")
.methods
.first {
it.declaringClass.name == "com.google.firebase.messaging.FirebaseMessagingService" &&
it.returnType == Void::class.javaPrimitiveType &&
it.parameterCount == 1 &&
it.parameterTypes[0] == Intent::class.java
}
.hook(HookStage.BEFORE) { param ->
val intent = param.argNullable<Intent>(0) ?: return@hook
when (intent.getStringExtra("type")?.lowercase(Locale.ROOT)) {
"abandon_audio", "abandon_video" -> handleCallEnded("firebase:${intent.getStringExtra("type")}")
}
}
}
listOf(
"com.snapchat.talkcorev3.TalkCore\$CppProxy",
"com.snapchat.talkcorev4.TalkCore\$CppProxy",
"com.snapchat.talkcore.TalkCore\$CppProxy"
).forEach { className ->
runCatching {
findClass(className).apply {
hook("updateTSCallingSession", HookStage.BEFORE) { param ->
val params = param.argNullable<Any>(0) ?: return@hook
val inCall = params.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hook
if (inCall) handleCallStarted("talkcore:update_true")
else handleCallEnded("talkcore:update_false")
}
hook("disposeTSCallingSession", HookStage.BEFORE) {
handleCallEnded("talkcore:dispose")
}
}
}
}
listOf(
"com.snapchat.talkcorev3.TSCallingStateUpdateParams",
"com.snapchat.talkcorev4.TSCallingStateUpdateParams",
"com.snapchat.talkcore.TSCallingStateUpdateParams"
).forEach { className ->
runCatching {
findClass(className).hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val inCall = instance.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hookConstructor
if (inCall) handleCallStarted("params_ctor:true")
else handleCallEnded("params_ctor:false")
}
}
}
}
}

View File

@@ -0,0 +1,281 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.core.event.events.impl.ConversationUpdateEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.features.Feature
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.sin
class ConversationSoundEffects : Feature("Conversation Sound Effects") {
private val seenIncomingMessageIds = LinkedHashSet<Long>()
private val maxTrackedMessages = 512
private data class BubbleSpec(
val durationMs: Int,
val startFreqHz: Double,
val endFreqHz: Double,
val overtoneFreqHz: Double,
val amplitude: Double
)
private data class BubbleStep(
val spec: BubbleSpec,
val pauseAfterMs: Long = 0L
)
private val iMessageSendBubble = BubbleSpec(
durationMs = 78,
startFreqHz = 1160.0,
endFreqHz = 690.0,
overtoneFreqHz = 1820.0,
amplitude = 0.50
)
private val iMessageReceiveBubble = BubbleSpec(
durationMs = 92,
startFreqHz = 1040.0,
endFreqHz = 640.0,
overtoneFreqHz = 1680.0,
amplitude = 0.46
)
private val whatsappSendBubble = BubbleSpec(
durationMs = 86,
startFreqHz = 860.0,
endFreqHz = 520.0,
overtoneFreqHz = 1410.0,
amplitude = 0.52
)
private val whatsappReceiveBubble = BubbleSpec(
durationMs = 94,
startFreqHz = 920.0,
endFreqHz = 560.0,
overtoneFreqHz = 1520.0,
amplitude = 0.50
)
private val telegramSendPrimary = BubbleSpec(
durationMs = 58,
startFreqHz = 1110.0,
endFreqHz = 820.0,
overtoneFreqHz = 1710.0,
amplitude = 0.42
)
private val telegramSendAccent = BubbleSpec(
durationMs = 34,
startFreqHz = 1360.0,
endFreqHz = 980.0,
overtoneFreqHz = 2060.0,
amplitude = 0.22
)
private val telegramReceivePrimary = BubbleSpec(
durationMs = 72,
startFreqHz = 1080.0,
endFreqHz = 780.0,
overtoneFreqHz = 1680.0,
amplitude = 0.44
)
private val telegramReceiveAccent = BubbleSpec(
durationMs = 42,
startFreqHz = 1280.0,
endFreqHz = 940.0,
overtoneFreqHz = 1940.0,
amplitude = 0.18
)
private val subtleSendBubble = BubbleSpec(
durationMs = 60,
startFreqHz = 760.0,
endFreqHz = 520.0,
overtoneFreqHz = 1180.0,
amplitude = 0.26
)
private val subtleReceiveBubble = BubbleSpec(
durationMs = 66,
startFreqHz = 800.0,
endFreqHz = 560.0,
overtoneFreqHz = 1260.0,
amplitude = 0.24
)
private fun currentConversationId() = context.feature(Messaging::class).openedConversationUUID?.toString()
private fun buildBubblePcm(spec: BubbleSpec, sampleRate: Int = 44_100): ByteArray {
val sampleCount = (sampleRate * (spec.durationMs / 1000.0)).toInt().coerceAtLeast(1)
val pcm = ByteArray(sampleCount * 2)
for (i in 0 until sampleCount) {
val progress = i.toDouble() / sampleCount.toDouble()
val envelope = exp(-4.8 * progress) * (1.0 - exp(-20.0 * progress))
val freq = spec.startFreqHz + (spec.endFreqHz - spec.startFreqHz) * progress
val t = i.toDouble() / sampleRate.toDouble()
val fundamental = sin(2.0 * PI * freq * t)
val overtone = 0.18 * sin(2.0 * PI * spec.overtoneFreqHz * t)
val airyTail = 0.08 * sin(2.0 * PI * (freq * 0.48) * t)
val warmth = 0.14 * sin(2.0 * PI * (freq * 0.24) * t)
val sample = ((fundamental + overtone + airyTail + warmth) * envelope * spec.amplitude)
.coerceIn(-1.0, 1.0)
val shortValue = (sample * Short.MAX_VALUE).toInt().toShort()
pcm[i * 2] = (shortValue.toInt() and 0xFF).toByte()
pcm[i * 2 + 1] = ((shortValue.toInt() shr 8) and 0xFF).toByte()
}
return pcm
}
private fun playBubble(spec: BubbleSpec) {
if (context.isMainActivityPaused) return
context.executeAsync {
val sampleRate = 44_100
val pcm = buildBubblePcm(spec, sampleRate)
val audioTrack = AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
pcm.size,
AudioTrack.MODE_STATIC,
AudioManager.AUDIO_SESSION_ID_GENERATE
)
runCatching {
audioTrack.write(pcm, 0, pcm.size)
audioTrack.play()
delay(spec.durationMs.toLong() + 24L)
}.also {
runCatching {
audioTrack.stop()
audioTrack.release()
}
}
}
}
private fun playBubbleSequence(steps: List<BubbleStep>) {
if (context.isMainActivityPaused) return
context.executeAsync {
steps.forEach { step ->
val sampleRate = 44_100
val pcm = buildBubblePcm(step.spec, sampleRate)
val audioTrack = AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
pcm.size,
AudioTrack.MODE_STATIC,
AudioManager.AUDIO_SESSION_ID_GENERATE
)
runCatching {
audioTrack.write(pcm, 0, pcm.size)
audioTrack.play()
delay(step.spec.durationMs.toLong() + step.pauseAfterMs + 18L)
}.also {
runCatching {
audioTrack.stop()
audioTrack.release()
}
}
}
}
}
private fun playStyledSend() {
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"imessage" -> playBubble(iMessageSendBubble)
"whatsapp" -> playBubble(whatsappSendBubble)
"telegram" -> playBubbleSequence(
listOf(
BubbleStep(telegramSendPrimary, pauseAfterMs = 16L),
BubbleStep(telegramSendAccent)
)
)
else -> playBubble(subtleSendBubble)
}
}
private fun playStyledReceive() {
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"imessage" -> playBubble(iMessageReceiveBubble)
"whatsapp" -> playBubbleSequence(
listOf(
BubbleStep(whatsappReceiveBubble, pauseAfterMs = 12L),
BubbleStep(
whatsappReceiveBubble.copy(
durationMs = 42,
startFreqHz = 1210.0,
endFreqHz = 860.0,
overtoneFreqHz = 1980.0,
amplitude = 0.20
)
)
)
)
"telegram" -> playBubbleSequence(
listOf(
BubbleStep(telegramReceivePrimary, pauseAfterMs = 14L),
BubbleStep(telegramReceiveAccent)
)
)
else -> playBubble(subtleReceiveBubble)
}
}
private fun markSeen(messageId: Long): Boolean {
synchronized(seenIncomingMessageIds) {
val added = seenIncomingMessageIds.add(messageId)
while (seenIncomingMessageIds.size > maxTrackedMessages) {
seenIncomingMessageIds.remove(seenIncomingMessageIds.first())
}
return added
}
}
override fun init() {
if (context.config.messaging.conversationSoundEffectsStyle.get() == "disabled") return
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.destinations.conversations?.none { it.toString() == activeConversationId } != false) return@subscribe
event.addCallbackResult("onSuccess") {
playStyledSend()
}
}
context.event.subscribe(ConversationUpdateEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.conversationId != activeConversationId) return@subscribe
val myUserId = context.database.myUserId ?: return@subscribe
event.messages
.asSequence()
.filter { it.senderId?.toString() != myUserId }
.mapNotNull { it.messageDescriptor?.messageId }
.filter { markSeen(it) }
.firstOrNull()
?.let {
playStyledReceive()
}
}
}
}

View File

@@ -60,6 +60,14 @@ class Messaging : Feature("Messaging") {
currentConversationId()?.let { stealthMode.canUseRule(it) } == true
}
private fun shouldSpoofViewingGalleryPresence(stealthMode: StealthMode): Boolean {
return shouldHideBitmojiPresence(stealthMode) || context.config.messaging.spoofViewingGalleryPresence.get()
}
private fun shouldSpoofReplyCameraPresence(stealthMode: StealthMode): Boolean {
return shouldHideBitmojiPresence(stealthMode) || context.config.messaging.spoofReplyCameraPresence.get()
}
private fun shouldHideTyping(stealthMode: StealthMode, hideTypingIndicator: HideTypingIndicator): Boolean {
return context.config.messaging.hideTypingNotifications.get() ||
currentConversationId()?.let { stealthMode.canUseRule(it) || hideTypingIndicator.canUseRule(it) } == true
@@ -156,6 +164,8 @@ class Messaging : Feature("Messaging") {
classReference.getAsClass()?.let { wrapperClass ->
val bitmojiMethodNames = mutableSetOf<String>()
val viewingGalleryMethodNames = mutableSetOf<String>()
val replyCameraMethodNames = mutableSetOf<String>()
val typingMethodNames = mutableSetOf<String>()
val peekingMethodNames = mutableSetOf<String>()
@@ -165,14 +175,24 @@ class Messaging : Feature("Messaging") {
if (parameterTypes.any { parameterType ->
listOf(
"PlatformChatVisibleAction",
"PlatformChatHiddenAction",
"PlatformViewingChatMediaAction",
"PlatformUsingReplyCameraAction"
"PlatformChatHiddenAction"
).any { parameterType.name.contains(it) }
}) {
bitmojiMethodNames.add(method.name)
}
if (parameterTypes.any { parameterType ->
parameterType.name.contains("PlatformViewingChatMediaAction")
}) {
viewingGalleryMethodNames.add(method.name)
}
if (parameterTypes.any { parameterType ->
parameterType.name.contains("PlatformUsingReplyCameraAction")
}) {
replyCameraMethodNames.add(method.name)
}
if (parameterTypes.any { parameterType ->
parameterType.name.contains("PlatformTypingAction")
}) {
@@ -194,6 +214,22 @@ class Messaging : Feature("Messaging") {
}
}
viewingGalleryMethodNames.forEach { methodName ->
wrapperClass.hook(methodName, HookStage.BEFORE, {
shouldSpoofViewingGalleryPresence(stealthMode)
}) {
it.setResult(null)
}
}
replyCameraMethodNames.forEach { methodName ->
wrapperClass.hook(methodName, HookStage.BEFORE, {
shouldSpoofReplyCameraPresence(stealthMode)
}) {
it.setResult(null)
}
}
typingMethodNames.forEach { methodName ->
wrapperClass.hook(methodName, HookStage.BEFORE, {
shouldHideTyping(stealthMode, hideTypingIndicator)
@@ -214,8 +250,8 @@ class Messaging : Feature("Messaging") {
val instance = param.thisObject<Any>()
clearField(instance, "PlatformChatVisibleAction", shouldHideBitmojiPresence(stealthMode))
clearField(instance, "PlatformChatHiddenAction", shouldHideBitmojiPresence(stealthMode))
clearField(instance, "PlatformViewingChatMediaAction", shouldHideBitmojiPresence(stealthMode))
clearField(instance, "PlatformUsingReplyCameraAction", shouldHideBitmojiPresence(stealthMode))
clearField(instance, "PlatformViewingChatMediaAction", shouldSpoofViewingGalleryPresence(stealthMode))
clearField(instance, "PlatformUsingReplyCameraAction", shouldSpoofReplyCameraPresence(stealthMode))
clearField(instance, "PlatformTypingAction", shouldHideTyping(stealthMode, hideTypingIndicator))
clearField(instance, "PlatformStartPeekingAction", shouldHidePeek(stealthMode))
}

View File

@@ -25,6 +25,12 @@ import java.text.DateFormat
import java.util.Date
class FriendTracker : Feature("Friend Tracker") {
companion object {
private const val PRESENCE_PEEKING_BIT = 8
private const val PRESENCE_REPLY_CAMERA_BIT = 9
private const val PRESENCE_CHAT_MEDIA_BIT = 10
}
private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state)
private val tracker by lazyBridge { context.bridgeClient.getTracker() }
private val translation by lazy { context.translation.getCategory("friend_tracker_notifications") }
@@ -37,6 +43,8 @@ class FriendTracker : Feature("Friend Tracker") {
))
} }
private val conversationEntries = mutableMapOf<Pair<String, String>, Long>()
private val galleryEntries = mutableMapOf<Pair<String, String>, Long>()
private val replyCameraEntries = mutableMapOf<Pair<String, String>, Long>()
private val peekingStateListeners = mutableListOf<(String, String, Boolean) -> Unit>()
fun addOnPeekingStateChangedListener(listener: (conversationId: String, userId: String, peeking: Boolean) -> Unit) {
@@ -104,7 +112,12 @@ class FriendTracker : Feature("Friend Tracker") {
context.log.verbose("dispatching $action for $eventType in $conversationName")
val iCanSeeYouDetails = if (eventType == TrackerEventType.I_CAN_SEE_YOU) buildICanSeeYouDetails(extras) else ""
val iCanSeeYouDetails = when (eventType) {
TrackerEventType.I_CAN_SEE_YOU,
TrackerEventType.I_CAN_SEE_YOU_2,
TrackerEventType.I_CAN_SEE_YOU_3 -> buildICanSeeYouDetails(extras)
else -> ""
}
val notificationText = translation[eventType.key]
.replace("{friend}", authorName)
.replace("{conversation}", conversationName)
@@ -133,7 +146,7 @@ class FriendTracker : Feature("Friend Tracker") {
}
}
private fun buildICanSeeYouExtras(entry: Long?, exit: Long?, duration: Long?) = listOf(
private fun buildTimedActivityExtras(entry: Long?, exit: Long?, duration: Long?) = listOf(
entry ?: -1,
exit ?: -1,
duration ?: -1
@@ -189,10 +202,22 @@ class FriendTracker : Feature("Friend Tracker") {
(currentState == null || oldState?.bitmojiPresent == false) && oldState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_EXIT
oldState?.typing == false && currentState?.typing == true -> if (currentState.speaking) TrackerEventType.STARTED_SPEAKING else TrackerEventType.STARTED_TYPING
oldState?.typing == true && (currentState == null || !currentState.typing) -> if (oldState.speaking) TrackerEventType.STOPPED_SPEAKING else TrackerEventType.STOPPED_TYPING
(oldState == null || !oldState.peeking) && currentState?.peeking == true -> TrackerEventType.STARTED_PEEKING
oldState?.peeking == true && (currentState == null || !currentState.peeking) -> TrackerEventType.STOPPED_PEEKING
(oldState == null || !oldState.usingReplyCamera) && currentState?.usingReplyCamera == true -> TrackerEventType.STARTED_USING_REPLY_CAMERA
oldState?.usingReplyCamera == true && (currentState == null || !currentState.usingReplyCamera) -> TrackerEventType.STOPPED_USING_REPLY_CAMERA
(oldState == null || !oldState.viewingChatMedia) && currentState?.viewingChatMedia == true -> TrackerEventType.STARTED_VIEWING_CHAT_MEDIA
oldState?.viewingChatMedia == true && (currentState == null || !currentState.viewingChatMedia) -> TrackerEventType.STOPPED_VIEWING_CHAT_MEDIA
(oldState == null || !oldState.peeking) &&
currentState?.peeking == true &&
currentState.usingReplyCamera != true &&
oldState?.usingReplyCamera != true -> TrackerEventType.STARTED_PEEKING
oldState?.peeking == true &&
(currentState == null || !currentState.peeking) &&
currentState?.usingReplyCamera != true &&
oldState.usingReplyCamera != true -> TrackerEventType.STOPPED_PEEKING
else -> null
} ?: return
}
eventType ?: return
when (eventType) {
TrackerEventType.CONVERSATION_ENTER -> {
@@ -206,7 +231,35 @@ class FriendTracker : Feature("Friend Tracker") {
TrackerEventType.I_CAN_SEE_YOU,
conversationId,
userId,
buildICanSeeYouExtras(entry, exit, entry?.let { exit - it })
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
)
}
TrackerEventType.STARTED_VIEWING_CHAT_MEDIA -> {
galleryEntries[conversationId to userId] = System.currentTimeMillis()
}
TrackerEventType.STOPPED_VIEWING_CHAT_MEDIA -> {
val key = conversationId to userId
val exit = System.currentTimeMillis()
val entry = galleryEntries.remove(key)
dispatchEvents(
TrackerEventType.I_CAN_SEE_YOU_2,
conversationId,
userId,
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
)
}
TrackerEventType.STARTED_USING_REPLY_CAMERA -> {
replyCameraEntries[conversationId to userId] = System.currentTimeMillis()
}
TrackerEventType.STOPPED_USING_REPLY_CAMERA -> {
val key = conversationId to userId
val exit = System.currentTimeMillis()
val entry = replyCameraEntries.remove(key)
dispatchEvents(
TrackerEventType.I_CAN_SEE_YOU_3,
conversationId,
userId,
buildTimedActivityExtras(entry, exit, entry?.let { exit - it })
)
}
else -> {}
@@ -266,14 +319,20 @@ class FriendTracker : Feature("Friend Tracker") {
userIds.add(participantUserId)
if (participantUserId == context.database.myUserId) return@eachBuffer
val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' } ?: return@eachBuffer
val usingReplyCamera = stateMap.getOrElse(PRESENCE_REPLY_CAMERA_BIT) { false }
val viewingChatMedia = stateMap.getOrElse(PRESENCE_CHAT_MEDIA_BIT) { false }
val peeking = stateMap.getOrElse(PRESENCE_PEEKING_BIT) { false }
presenceMap[participantUserId] = FriendPresenceState(
bitmojiPresent = stateMap[0],
typing = stateMap[4],
wasTyping = stateMap[5],
speaking = stateMap[6] && stateMap[4],
// Snapchat appears to have shifted the peeking flag by one bit on newer builds.
peeking = stateMap.getOrElse(8) { false } || stateMap.getOrElse(9) { false }
// Snapchat moved peeking by one bit on newer builds and added
// dedicated chat-presence flags for reply camera and chat media viewing.
peeking = peeking,
usingReplyCamera = usingReplyCamera,
viewingChatMedia = viewingChatMedia
)
}

View File

@@ -0,0 +1,312 @@
package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.animation.ValueAnimator
import android.app.Activity
import android.database.sqlite.SQLiteDatabase
import android.hardware.camera2.CaptureRequest
import android.media.MediaRecorder
import android.os.Build
import android.transition.Transition
import android.os.HandlerThread
import android.os.Process
import android.util.Range
import android.view.View
import android.view.ViewPropertyAnimator
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.lang.Thread
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.ThreadPoolExecutor
import me.eternal.purrfectsnap.core.features.Feature
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 okhttp3.Dispatcher
class PerformanceMode : Feature("Performance Mode") {
override fun init() {
val profile = context.config.global.performanceMode.profile.getNullable() ?: return
val isMaxProfile = profile == "max"
val threadPriority = if (isMaxProfile) {
Process.THREAD_PRIORITY_DISPLAY
} else {
Process.THREAD_PRIORITY_MORE_FAVORABLE
}
val minimumFrameRate = if (isMaxProfile) 60 else 45
val durationScale = if (isMaxProfile) 0.35f else 0.55f
val recyclerViewCacheSize = if (isMaxProfile) 64 else 32
val maxRequests = if (isMaxProfile) 192 else 96
val maxRequestsPerHost = if (isMaxProfile) 32 else 16
val minimumCoreThreads = if (isMaxProfile) 16 else 8
val prefetchItemCount = if (isMaxProfile) 24 else 12
val maxAnimationDurationMs = if (isMaxProfile) 90L else 140L
val maxScrollDurationMs = if (isMaxProfile) 120 else 180
val preferredRefreshRate = if (isMaxProfile) 120f else 90f
context.log.info(
"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 animatorDurationLog = firstHitLogger("ValueAnimator.setDuration")
val viewAnimatorDurationLog = firstHitLogger("ViewPropertyAnimator.setDuration")
val transitionDurationLog = firstHitLogger("Transition.setDuration")
val animationDurationLog = firstHitLogger("Animation.setDuration")
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 captureRequestLog = firstHitLogger("CaptureRequest.Builder.set")
val sustainedModeLog = firstHitLogger("Window.setSustainedPerformanceMode")
val refreshRateLog = firstHitLogger("Activity.preferredRefreshRate")
val overScrollerLog = firstHitLogger("OverScroller.startScroll")
fun isPerformanceSensitiveThread(name: String?): Boolean {
val normalizedName = name?.lowercase() ?: return false
return listOf("camera", "preview", "codec", "render", "gl", "transcod", "lens", "feed", "story", "opera", "messag", "network", "db", "disk").any {
normalizedName.contains(it)
}
}
HandlerThread::class.java.hookConstructor(HookStage.BEFORE) { param ->
if (param.args().size < 2) return@hookConstructor
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 ->
val thread = param.nullableThisObject<Any>() as? HandlerThread ?: return@hook
if (!isPerformanceSensitiveThread(thread.name)) return@hook
runCatching {
val tid = thread.threadId
if (tid > 0) {
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 = Thread.MAX_PRIORITY
}
threadStartLog("name=${thread.name} priority=${thread.priority}")
}
ThreadPoolExecutor::class.java.hookConstructor(HookStage.AFTER) { param ->
val executor = param.thisObject<ThreadPoolExecutor>()
runCatching {
val targetCorePoolSize = executor.maximumPoolSize.coerceAtLeast(1).coerceAtMost(minimumCoreThreads.coerceAtLeast(executor.corePoolSize))
if (executor.corePoolSize < targetCorePoolSize) {
executor.corePoolSize = targetCorePoolSize
}
executor.allowCoreThreadTimeOut(false)
executor.prestartAllCoreThreads()
executorLog("core=${executor.corePoolSize} max=${executor.maximumPoolSize} active=${executor.activeCount}")
}
}
Dispatcher::class.java.hookConstructor(HookStage.AFTER) { param ->
val dispatcher = param.thisObject<Dispatcher>()
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)
}
animatorDurationLog("requested=$original applied=${param.arg<Long>(0)}")
}
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)
}
viewAnimatorDurationLog("requested=$original applied=${param.arg<Long>(0)}")
}
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)
}
transitionDurationLog("requested=$original applied=${param.arg<Long>(0)}")
}
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)
}
animationDurationLog("requested=$original applied=${param.arg<Long>(0)}")
}
RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param ->
val recyclerView = param.thisObject<RecyclerView>()
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
recyclerView.overScrollMode = View.OVER_SCROLL_NEVER
recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
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.setLayerType(View.LAYER_TYPE_HARDWARE, null)
if (isMaxProfile) {
recyclerView.itemAnimator = null
}
recyclerAdapterLog("cache=$recyclerViewCacheSize adapter=${param.argNullable<Any>(0)?.javaClass?.name}")
}
RecyclerView::class.java.hook("setLayoutManager", HookStage.AFTER) { param ->
val recyclerView = param.thisObject<RecyclerView>()
val layoutManager = param.argNullable<Any>(0)
when (layoutManager) {
is LinearLayoutManager -> {
layoutManager.isItemPrefetchEnabled = true
layoutManager.initialPrefetchItemCount = prefetchItemCount
}
is StaggeredGridLayoutManager -> {
layoutManager.isItemPrefetchEnabled = true
layoutManager.gapStrategy = StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
}
}
recyclerView.setLayerType(View.LAYER_TYPE_HARDWARE, null)
recyclerLayoutManagerLog("layoutManager=${layoutManager?.javaClass?.name} prefetch=$prefetchItemCount")
}
fun SQLiteDatabase.applyPerformancePragmas() {
runCatching { execSQL("PRAGMA synchronous = NORMAL") }
runCatching { execSQL("PRAGMA temp_store = MEMORY") }
runCatching { execSQL("PRAGMA cache_size = -32768") }
runCatching { execSQL("PRAGMA mmap_size = 268435456") }
runCatching { execSQL("PRAGMA journal_size_limit = 1048576") }
runCatching { execSQL("PRAGMA optimize") }
}
SQLiteDatabase::class.java.hook("openDatabase", HookStage.AFTER) { param ->
(param.getResult() as? SQLiteDatabase)?.also {
it.applyPerformancePragmas()
sqliteOpenLog("path=${param.argNullable<Any>(0)}")
}
}
SQLiteDatabase::class.java.hook("openOrCreateDatabase", HookStage.AFTER) { param ->
(param.getResult() as? SQLiteDatabase)?.also {
it.applyPerformancePragmas()
sqliteCreateLog("path=${param.argNullable<Any>(0)}")
}
}
MediaRecorder::class.java.hook("setVideoFrameRate", HookStage.BEFORE) { param ->
val currentRate = param.arg<Int>(0)
if (currentRate < minimumFrameRate) {
param.setArg(0, minimumFrameRate)
}
mediaRecorderLog("requested=$currentRate applied=${param.arg<Int>(0)}")
}
OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param ->
if (param.args().size >= 5) {
val original = param.arg<Int>(4)
val updated = original.coerceAtMost(maxScrollDurationMs)
if (updated != original) {
param.setArg(4, updated)
}
overScrollerLog("requested=$original applied=${param.arg<Int>(4)}")
}
}
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
val key = param.arg<CaptureRequest.Key<*>>(0)
when (key) {
CaptureRequest.EDGE_MODE -> param.setArg(1, CaptureRequest.EDGE_MODE_FAST)
CaptureRequest.NOISE_REDUCTION_MODE -> param.setArg(1, CaptureRequest.NOISE_REDUCTION_MODE_FAST)
CaptureRequest.HOT_PIXEL_MODE -> param.setArg(1, CaptureRequest.HOT_PIXEL_MODE_FAST)
CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE -> param.setArg(1, CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE_FAST)
CaptureRequest.CONTROL_AF_MODE -> param.setArg(1, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE -> param.setArg(1, CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_OFF)
CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE -> {
val currentRange = param.argNullable<Any>(1) as? Range<*>
val lower = (currentRange?.lower as? Int) ?: minimumFrameRate
val upper = (currentRange?.upper as? Int) ?: minimumFrameRate
if (upper < minimumFrameRate) {
param.setArg(1, Range(lower.coerceAtMost(minimumFrameRate), minimumFrameRate))
}
}
}
captureRequestLog("key=${key.name} value=${param.argNullable<Any>(1)}")
}
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
}
refreshRateLog("activity=${activity::class.java.name} refreshRate=$targetRefreshRate")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isMaxProfile) {
runCatching {
activity.window.setSustainedPerformanceMode(true)
sustainedModeLog("activity=${activity::class.java.name}")
}
}
}
onNextActivityCreate {
applyActivityPerformanceTuning(it)
}
}
}

View File

@@ -0,0 +1,217 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.gson.reflect.TypeToken
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.database.impl.ConversationMessage
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.ui.CustomComposable
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
import me.eternal.purrfectsnap.core.wrapper.impl.sanitizeForLayout
import kotlinx.coroutines.delay
class LocalPinnedMessages : Feature("Local Pinned Messages") {
data class PinnedMessageSnapshot(
val messageId: Long,
val senderName: String,
val preview: String,
val timestamp: Long
)
private val prefs by lazy {
context.androidContext.getSharedPreferences("purrfectsnap_local_pinned_messages", 0)
}
private var revision by mutableLongStateOf(0L)
private var trackedChatLayout by mutableStateOf<View?>(null)
private var trackedChatVisibility by mutableStateOf(false)
private fun readPins(): MutableMap<String, PinnedMessageSnapshot> {
val json = prefs.getString("pins", null) ?: return mutableMapOf()
return runCatching {
context.gson.fromJson<MutableMap<String, PinnedMessageSnapshot>>(
json,
object : TypeToken<MutableMap<String, PinnedMessageSnapshot>>() {}.type
) ?: mutableMapOf()
}.getOrElse { mutableMapOf() }
}
private fun writePins(pins: Map<String, PinnedMessageSnapshot>) {
prefs.edit().putString("pins", context.gson.toJson(pins)).apply()
revision++
}
private fun resolveMessagePreview(message: ConversationMessage): String {
val messageContainer = message.messageContent?.let { ProtoReader(it) }?.followPath(4, 4)
val contentType = ContentType.fromMessageContainer(messageContainer) ?: ContentType.fromId(message.contentType)
return messageContainer?.getBuffer()?.getMessageText(contentType)
?: "[${context.translation.getCategory("content_type")[contentType.name]}]"
}
private fun resolveSenderName(message: ConversationMessage): String {
return message.senderId?.let { senderId ->
context.database.getFriendInfo(senderId)?.let { it.displayName ?: it.mutableUsername }
} ?: context.translation.getCategory("logger_history")["unknown_sender"]
}
fun pinFocusedMessage() {
val messaging = context.feature(Messaging::class)
val conversationId = messaging.openedConversationUUID?.toString() ?: return
val messageId = messaging.lastFocusedMessageId.takeIf { it > 0 } ?: return
val message = context.database.getConversationMessageFromId(messageId) ?: return
val pins = readPins()
pins[conversationId] = PinnedMessageSnapshot(
messageId = messageId,
senderName = resolveSenderName(message),
preview = resolveMessagePreview(message).sanitizeForLayout(),
timestamp = message.creationTimestamp
)
writePins(pins)
context.shortToast(context.translation["local_pinned_messages.pinned_toast"])
}
fun unpinFocusedConversation() {
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return
val pins = readPins()
if (pins.remove(conversationId) != null) {
writePins(pins)
context.shortToast(context.translation["local_pinned_messages.unpinned_toast"])
}
}
fun hasPinnedMessageForOpenedConversation(): Boolean {
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return false
return readPins().containsKey(conversationId)
}
private fun isActuallyVisible(view: View): Boolean {
if (!view.isShown || view.visibility != View.VISIBLE || !view.isAttachedToWindow) return false
if (view.width <= 0 || view.height <= 0 || view.alpha <= 0f) return false
return view.getGlobalVisibleRect(Rect())
}
private fun trackChatLayoutCandidate(view: View?) {
view ?: return
trackedChatLayout = view
trackedChatVisibility = isActuallyVisible(view)
}
override fun init() {
context.event.subscribe(AddViewEvent::class) { event ->
when {
event.parent.javaClass.name.endsWith("ChatInputLayout") -> {
trackChatLayoutCandidate(event.parent)
}
event.viewClassName.endsWith("ChatInputLayout") -> {
trackChatLayoutCandidate(event.view)
}
}
}
onNextActivityCreate {
trackedChatLayout = null
trackedChatVisibility = false
}
lateinit var pinnedComposable: CustomComposable
pinnedComposable = {
revision
val trackedLayout = trackedChatLayout
var currentConversationId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(revision, trackedLayout) {
while (true) {
currentConversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
trackedChatVisibility = trackedLayout?.let { isActuallyVisible(it) } == true
delay(16)
}
}
val conversationId = currentConversationId
if (conversationId != null && trackedChatVisibility) {
val pinned = remember(revision, conversationId) { readPins()[conversationId] }
if (pinned != null) {
Box(
modifier = Modifier
.padding(top = 62.dp, start = 14.dp, end = 14.dp)
.align(Alignment.TopCenter)
) {
PurrfectOverlayTheme {
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectOverlayPalette.cardOverlayColor.copy(alpha = 0.95f), shape)
.border(1.dp, Color.White.copy(alpha = 0.10f), shape)
.clickable { }
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Outlined.PushPin,
contentDescription = null,
tint = PurrfectOverlayPalette.glowPrimary
)
Text(
text = pinned.preview,
color = Color.White.copy(alpha = 0.95f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.clickable {
unpinFocusedConversation()
}
)
}
}
}
}
}
}
context.inAppOverlay.addCustomComposable(pinnedComposable)
}
}

View File

@@ -14,6 +14,7 @@ import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.features.impl.experiments.ConvertMessageLocally
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
import me.eternal.purrfectsnap.core.features.impl.ui.LocalPinnedMessages
import me.eternal.purrfectsnap.core.ui.ViewTagState
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
@@ -212,7 +213,24 @@ class ChatActionMenu : AbstractMenu() {
})
}
val pinnedMessages = context.feature(LocalPinnedMessages::class)
injectButton(Button(viewGroup.context).apply {
text = if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
this@ChatActionMenu.context.translation["chat_action_menu.unpin_local_message"]
} else {
this@ChatActionMenu.context.translation["chat_action_menu.pin_local_message"]
}
setOnClickListener {
closeActionMenu()
if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
pinnedMessages.unpinFocusedConversation()
} else {
pinnedMessages.pinFocusedMessage()
}
}
})
viewGroup.addView(buttonContainer)
}
}
}

View File

@@ -41,6 +41,7 @@ import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDeco
import me.eternal.purrfectsnap.core.features.impl.experiments.ConvertMessageLocally
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
import me.eternal.purrfectsnap.core.features.impl.ui.LocalPinnedMessages
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.ui.debugEditText
import me.eternal.purrfectsnap.core.ui.iterateParent
@@ -343,6 +344,20 @@ class NewChatActionMenu : AbstractMenu() {
)
})
}
val pinnedMessages = context.feature(LocalPinnedMessages::class)
ListButton(
icon = Icons.Outlined.PushPin,
text = if (pinnedMessages.hasPinnedMessageForOpenedConversation()) context.translation["chat_action_menu.unpin_local_message"] else context.translation["chat_action_menu.pin_local_message"],
modifier = Modifier.clickable {
closeActionMenu()
if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
pinnedMessages.unpinFocusedConversation()
} else {
pinnedMessages.pinFocusedMessage()
}
}
)
}
}.apply {
layoutParams = LinearLayout.LayoutParams(

View File

@@ -19,7 +19,36 @@ class MediaInfo(obj: Any?) : AbstractWrapper(obj) {
if (it.isEmpty()) {
throw RuntimeException("MediaInfo is empty")
}
instance = it[0]!!
// Select highest quality media by comparing width * height
// Use explicit field name search to avoid relying on field order
instance = it.filterNotNull().maxByOrNull { mediaObj ->
runCatching {
val fields = mediaObj.javaClass.fields
// Search for width and height fields by name (case-insensitive)
// Common patterns: "width", "mWidth", "height", "mHeight"
val widthField = fields.find { f ->
f.name.equals("width", ignoreCase = true) ||
f.name.equals("mWidth", ignoreCase = true)
}
val heightField = fields.find { f ->
f.name.equals("height", ignoreCase = true) ||
f.name.equals("mHeight", ignoreCase = true)
}
// Validate fields exist and are integers before calculating resolution
if (widthField != null && heightField != null &&
(widthField.type == Int::class.javaPrimitiveType || widthField.type == Int::class.java) &&
(heightField.type == Int::class.javaPrimitiveType || heightField.type == Int::class.java)) {
widthField.isAccessible = true
heightField.isAccessible = true
widthField.getInt(mediaObj) * heightField.getInt(mediaObj)
} else {
0
}
}.getOrDefault(0)
} ?: it.filterNotNull().firstOrNull() ?: it.firstOrNull()
}
}
}

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.5.9
APP_VERSION_CODE=308
APP_VERSION_NAME=1.6.4
APP_VERSION_CODE=318
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c