22 Commits

Author SHA1 Message Date
ΞTΞRNAL
b44558babd fix(PR): Auto Open stabilization and Spotlight/Story video download fixes by Kaladin
Auto Open stabilization and Spotlight/Story video download fixes.
2026-04-05 15:37:00 +05:30
ΞTΞRNAL
e97a37d18e v1.6.8 2026-04-05 15:35:39 +05:30
DarkKnight2122
281d55689a Spotlight/Story video download fix 2026-04-05 03:28:17 +05:30
DarkKnight2122
7475998961 Optimize background engine & sync with Notifications for instant previews 2026-04-05 00:53:56 +05:30
ΞTΞRNAL
f01b6c2f9a add fresh changelogs
Updated changelog for version 1.6.6 with multiple fixes and new features, including pre-fetch snaps toggle and disk optimization improvements.
2026-04-02 20:48:00 +05:30
ΞTΞRNAL
98f8d6ebea fix(PR): auto open notification fix by Kaladin
fix: auto open notification fix
2026-04-02 20:39:32 +05:30
DarkKnight2122
015226cb40 fix: auto open notification fix 2026-04-02 19:43:59 +05:30
ΞTΞRNAL
ad06b0dffb v1.6.6 2026-04-02 19:00:08 +05:30
ΞTΞRNAL
4b4d64e8eb fix version bump 2026-04-02 14:18:43 +05:30
ΞTΞRNAL
3eec22c615 fix(PR): Restore Auto Open stability by Kaladin
Restore Auto Open stability
2026-04-02 14:01:08 +05:30
ΞTΞRNAL
9a2e6065dc Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-04-02 13:59:43 +05:30
ΞTΞRNAL
8e00c29a59 v1.6.5 2026-04-02 13:57:36 +05:30
DarkKnight2122
9ec9517ec5 fix(core): Restore Auto Open stability 2026-04-02 06:45:13 +05:30
ΞTΞRNAL
e822fc20b4 New announcement!
Added recommendation for using Performance Mode feature in Snapchat.
2026-04-02 02:41:12 +05:30
Ξ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
47 changed files with 5609 additions and 1807 deletions

View File

@@ -1 +1 @@
- Test
- All users are recommended to use the new Performance Mode feature! Go to the features tab and then select global and select performance mode and set it to Max. Then force stop and reopen Snapchat and you will feel the difference i.e. Snapchat will feel a lot faster.

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

@@ -146,8 +146,8 @@ class FFMpegProcessor(
val outputArguments = ArgumentList().apply {
this += "-preset" to (ffmpegOptions.preset.getNullable() ?: "ultrafast")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() } ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() } ?: "copy")
this += "-c:v" to (ffmpegOptions.customVideoCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "libx264")
this += "-c:a" to (ffmpegOptions.customAudioCodec.get().takeIf { it.isNotEmpty() }?.lowercase() ?: "copy")
this += "-crf" to ffmpegOptions.constantRateFactor.get().let { "\"$it\"" }
this += "-b:v" to ffmpegOptions.videoBitrate.get().toString() + "K"
this += "-b:a" to ffmpegOptions.audioBitrate.get().toString() + "K"

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

@@ -86,7 +86,7 @@ class AnnouncementCheckWorker(
val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(appContext, channelId)
.setSmallIcon(R.drawable.launcher_icon_monochrome)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(text)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)

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()
}
}
}
}
@@ -444,22 +466,26 @@ private class DialogWrapper(
this.onDismissRequest = onDismissRequest
this.properties = properties
setLayoutDirection(layoutDirection)
if (properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
val dialogWindow = window
val canUpdateWindowLayout = dialogWindow?.decorView?.let { decorView ->
isShowing && decorView.isAttachedToWindow && decorView.windowToken != null
} == true
if (canUpdateWindowLayout && properties.usePlatformDefaultWidth && !dialogLayout.usePlatformDefaultWidth) {
// Undo fixed size in internalOnLayout, which would suppress size changes when
// usePlatformDefaultWidth is true.
window?.setLayout(
dialogWindow.setLayout(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
}
dialogLayout.usePlatformDefaultWidth = properties.usePlatformDefaultWidth
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
if (canUpdateWindowLayout && Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
@OptIn(ExperimentalComposeUiApi::class)
if (properties.decorFitsSystemWindows) {
window?.setSoftInputMode(defaultSoftInputMode)
dialogWindow?.setSoftInputMode(defaultSoftInputMode)
} else {
@Suppress("DEPRECATION")
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
dialogWindow?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
}

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("310").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.8").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("324").get().toInt())
rootProject.ext.set("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,154 @@
## v1.6.8
- Fix: Many improvements to the Performance Mode feature(Max, turned on by Default), changes are pretty noticeable: faster loading of chats, long group messages optimizations, many snapmap optimizations
- Fix: Crash issues for some devices
- Fix: Block Ads
- Fix: Snapchat automatically restarting if kept idle
- Fix: Feed Flickering issue for some devices
- Fix: Implemented Dual-Thread Sync Architecture with a 1,000 depth that proactively scans the database for missed history, thus resolving the "failed to open..." and "Tap to load" snaps before marking them, clearing historical gaps automatically(tq to Kaladin)
- New: Implemented Screen Guard where Auto Open Engine now detects screen state and stops all notification work while the device is locked, thus reducing excessive battery drain and heat spike during background processing(tq to Kaladin)
- Fix: Disk read/write frequency increased from 1s to 5 minutes, to eliminate constant background disk friction, thus reducing overheating of the device while processing(tq to Kaladin)
- Fix: Slid chat switching delays from 2.0s down to 40ms, thus achieving near-instant processing of 1000+ snap bursts(tq to Kaladin)
- Fix: Removed "Auto Open pause while Gaming" toggle(tq to Kaladin)
- Fix: Reworked "Snap Pre-Fetch" toggle to the global Messaging settings page(tq to Kaladin)
- Fix: Refactored Auto Open engine notification cards to declutter it and removed redundant stats(tq to Kaladin)
- Fix: Corrected speed labels in the Auto Open notification to show "Idle" when monitoring and accurate notions (Full Speed/Throttled) when active(tq to Kaladin)
## v1.6.6
- Fix: Persistent auto open snap disabled notification(tq to Kaladin)
- Fix: Crash issues for some devices
- Fix: upload quality for snaps sent through send override
- Fix: Refactored the notification card for Auto Open to remove the looping progress bar while the engine is in Monitoring stage(tq to Kaladin)
- New: Implemented a "Pre Fetch snaps" toggle, when enabled this will pre-fetch the snaps in the auto open queue and load them for the engine to open them efficiently and thus reducing the "Failed to Open..." status(tq to Kaladin)
- Fix: The Auto Open engine now only fires the status update if the status text, processed count, or queue structural state actually changes. This reduces system wake-ups during idle periods which helps in battery drain management(tq to Kaladin)
- Fix: Disk Optimization: Previous versions wrote the auto open queue to the disk every 25 snaps, this update modified this to a 3-minute windowed save. This reduces Disk I/O by ~75%, keeping the device from overheating excessively during Auto Open processes(tq to Kaladin)
## v1.6.5
- New: Performance mode will now be set to max by default!
- Fix: Several optimizations to the performance mode feature which will make your snapchat experience more smooth!
- Fix: Crash issues for some devices after enabling spoof
- Fix: Notification Icon for the Announcements(tq to Kaladin)
- Fix: Auto Open Engine not processing, stuck on monitor/retry/failed loop causing overheating of the devices(tq to Kaladin)
- Fix: Refactor the Notification card for the Auto Open to remove dynamic progress bar to add the native progress bar(tq to Kaladin)
- Fix: Auto Open Statistics have been refactored to now show the dynamic queue status(tq to Kaladin)
- New: Auto Open Thermal Protection/Throttle toggle, when turned on the processing will be doubled down to reduce the temps of the device to maintain a study temp of 40 C and below(tq to Kaladin)
## 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!

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"
@@ -1300,10 +1321,6 @@
"name": "Call Metadata Notifier",
"description": "Shows a notification with captured call metadata after the call ends"
},
"conversation_sound_effects": {
"name": "Conversation Sound Effects",
"description": "Plays send and receive sounds inside an open conversation"
},
"conversation_sound_effects_style": {
"name": "Conversation Sound Style",
"description": "Choose the sound style used for in-conversation send and receive sounds"
@@ -1464,6 +1481,7 @@
"name": "Bypass Message Action Restrictions",
"description": "Allows you to react to a snap without having opened it or to save an unsaveable message"
},
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
"remove_groups_locked_status": {
"name": "Remove Groups Locked Status",
"description": "Allows you to view group information after being kicked"
@@ -1631,6 +1649,7 @@
"status_monitoring": "Monitoring",
"status_active": "Active",
"status_paused": "Paused",
"thermal_status_title": "Thermal Cooling (Throttled)",
"processed_count": "Opened",
"queue_size": "Queue",
"action_reset": "Reset Statistics",
@@ -1665,15 +1684,36 @@
"name": "Auto Open Compact Notification",
"description": "Use a smaller, single-line notification for status updates"
},
"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)"
},
"thermal_protection": {
"name": "Thermal Protection",
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40°C to prevent overheating"
},
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
"content_type_snap": "Snap",
"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." }
}
},
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
"instant_translation": {
"name": "Message Translator",
"description": "Configure the message translator"
},
"auto_delete_sent_messages": {
"name": "Auto Delete Sent Messages",
"description": "Automatically deletes sent messages after a specified time period",
@@ -1876,6 +1916,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"
@@ -2123,6 +2173,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",
@@ -2169,7 +2639,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": {
@@ -2475,6 +2945,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",
@@ -2529,6 +3002,7 @@
"null": "Device default FPS"
},
"conversation_sound_effects_style": {
"disabled": "Disabled",
"imessage": "iMessage",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
@@ -2561,7 +3035,8 @@
"NOTE": "Audio Note",
"SNAP": "Snap",
"SAVEABLE_SNAP": "Saveable Snap",
"null": "Snapchat Default"
"null": "Snapchat Default",
"multiple_media_toast": "You can only send one media at a time"
},
"strip_media_metadata": {
"hide_caption_text": "Hide Caption Text",
@@ -2608,6 +3083,11 @@
"custom_image_upload_format": {
"null": "Automatic"
},
"performance_profile": {
"smooth": "Smooth",
"max": "Max",
"null": "Disabled"
},
"update_check_frequency": {
"daily": "Daily",
"weekly": "Weekly",
@@ -3088,6 +3568,7 @@
"positive": "Yes",
"negative": "No",
"cancel": "Cancel",
"copy": "Copy",
"save": "Save",
"open": "Open",
"download": "Download",
@@ -3105,6 +3586,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",
@@ -3117,7 +3602,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": {
@@ -3194,12 +3681,13 @@
"failed_gallery_toast": "Failed saving to gallery {error}",
"dash_no_chapter": "No chapter found",
"dash_dialog": {
"title": "Download dash media",
"title": "DASH Download",
"download_all": "Download All",
"segment_text": "Segment {from} - {to}"
"snap_text": "Snap {from} - {to}"
},
"story_snap_dialog": {
"title": "Download story snaps",
"download_all": "Download All",
"select_all": "Select All",
"deselect_all": "Deselect All",
"snap_item": "Snap {index} of {total}"
@@ -3260,6 +3748,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",
@@ -3279,7 +3772,18 @@
"paused_message": "Processing paused. Queue preserved ({count} snaps)",
"status_paused": "Paused",
"status_monitoring": "Monitoring",
"thermal_status_title": "Thermal Cooling (Throttled)",
"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",
@@ -3353,6 +3857,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}",
@@ -3365,7 +3873,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",
@@ -3381,7 +3891,6 @@
"material3_strings": {
"date_range_picker_start_headline": "From",
"date_range_picker_end_headline": "To",
"date_range_picker_title": "Select date range",
"date_picker_switch_to_calendar_mode": "Calendar",
"date_picker_switch_to_input_mode": "Input",
"date_range_picker_scroll_to_previous_month": "Previous month",
@@ -3525,9 +4034,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",
@@ -3579,6 +4092,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",
@@ -3591,7 +4108,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"
}
}
},
@@ -3651,6 +4170,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.",
@@ -3689,17 +4209,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",
@@ -3846,10 +4374,3 @@
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
}

View File

@@ -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,18 @@ 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() }.apply {
profile.set("max")
}
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,17 @@ class MessagingTweaks : ConfigContainer() {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
}
val compactNotification = boolean("compact_notification", false)
val showLifetimeStats = boolean("show_lifetime_stats", false)
val showQueuePreview = boolean("show_queue_preview", true)
val thermalProtection = boolean("thermal_protection", false)
// 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) {
@@ -202,11 +207,45 @@ class MessagingTweaks : ConfigContainer() {
val showNotification = boolean("show_notification", defaultValue = true)
}
class InstantTranslationConfig : ConfigContainer(hasGlobalState = true) {
val enabled = boolean("enabled", false)
val sourceLanguage = string("source_language", defaultValue = "auto") {
inputCheck = { it.isNotBlank() }
}
val targetLanguage = string("target_language", defaultValue = "en") {
inputCheck = { it.isNotBlank() }
}
val showOriginal = boolean("show_original", defaultValue = true)
val showTranslation = boolean("show_translation", defaultValue = true)
val translationPosition = unique("translation_position", "above", "below", "inline") {
customOptionTranslationPath = "translation_position"
}.apply { set("below") }
val autoTranslate = boolean("auto_translate", defaultValue = true)
val translateOnTap = boolean("translate_on_tap", defaultValue = false)
val pauseOnError = boolean("pause_on_error", defaultValue = true)
val maxRetries = integer("max_retries", defaultValue = 3) {
inputCheck = { it.toIntOrNull()?.coerceIn(1, 10) != null }
}
val retryDelay = integer("retry_delay", defaultValue = 1000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(500) != null }
}
val supportedLanguages = multiple("supported_languages",
"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr", "nl", "pl", "sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "hr", "sk", "sl", "et", "lv", "lt", "mt", "ga", "cy"
) {
customOptionTranslationPath = "language_codes"
}.apply {
set(mutableListOf("en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr"))
}
}
val bypassScreenshotDetection = boolean("bypass_screenshot_detection") { requireRestart() }
val anonymousStoryViewing = boolean("anonymous_story_viewing")
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() }
@@ -224,12 +263,12 @@ class MessagingTweaks : ConfigContainer() {
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
val blockCalls = boolean("block_calls") { requireRestart() }
val callMetadataNotifier = boolean("call_metadata_notifier") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val conversationSoundEffects = boolean("conversation_sound_effects") { requireRestart() }
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "imessage", "telegram", "whatsapp", "subtle") {
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "disabled", "imessage", "telegram", "whatsapp", "subtle") {
requireRestart()
customOptionTranslationPath = "conversation_sound_effects_style"
addFlags(ConfigFlag.NO_TRANSLATE)
}.apply { set("imessage") }
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",
@@ -287,41 +326,9 @@ class MessagingTweaks : ConfigContainer() {
val doubleTapChatActionCustomEmoji = string("double_tap_chat_action_custom_emoji") {
inputCheck = { it.length == 2 && it.toByteArray(Charsets.UTF_8).size >= 4 } }
val autoReply = container("auto_reply", AutoReplyConfig()) { requireRestart() }
val autoOpenSnaps = container("auto_open_snaps", AutoOpenSnapsConfig()) { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
val autoDeleteSentMessages = container("auto_delete_sent_messages", AutoDeleteSentMessagesConfig()) { requireRestart() }
class InstantTranslationConfig : ConfigContainer(hasGlobalState = true) {
val enabled = boolean("enabled", false)
val sourceLanguage = string("source_language", defaultValue = "auto") {
inputCheck = { it.isNotBlank() }
}
val targetLanguage = string("target_language", defaultValue = "en") {
inputCheck = { it.isNotBlank() }
}
val showOriginal = boolean("show_original", defaultValue = true)
val showTranslation = boolean("show_translation", defaultValue = true)
val translationPosition = unique("translation_position", "above", "below", "inline") {
customOptionTranslationPath = "translation_position"
}.apply { set("below") }
val autoTranslate = boolean("auto_translate", defaultValue = true)
val translateOnTap = boolean("translate_on_tap", defaultValue = false)
val pauseOnError = boolean("pause_on_error", defaultValue = true)
val maxRetries = integer("max_retries", defaultValue = 3) {
inputCheck = { it.toIntOrNull()?.coerceIn(1, 10) != null }
}
val retryDelay = integer("retry_delay", defaultValue = 1000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(500) != null }
}
val supportedLanguages = multiple("supported_languages",
"en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr", "nl", "pl", "sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "hr", "sk", "sl", "et", "lv", "lt", "mt", "ga", "cy"
) {
customOptionTranslationPath = "language_codes"
}.apply {
set(mutableListOf("en", "es", "fr", "de", "it", "pt", "ru", "ja", "ko", "zh", "ar", "hi", "tr"))
}
}
val autoOpenSnaps = container("auto_open_snaps", AutoOpenSnapsConfig()) { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
val preFetchSnaps = boolean("pre_fetch_snaps", false)
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,25 +66,16 @@ 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",
"isom",
"iso2",
"iso3",
"iso4",
"iso5",
"iso6",
"avc1",
"dash",
"mif1",
"msnv",
"3gp4",
"3gp5",
"3gp6",
"3g2a",
"3g2b"
)
"mp41", "mp42", "isom", "iso2", "iso3", "iso4", "iso5", "iso6",
"avc1", "dash", "cmfc", "msnv", "3gp4", "3gp5", "3gp6", "3g2a", "3g2b",
"mp4v", "mp4a", "m4v ", "m4a ", "f4v ", "f4a "
) || majorBrand.isNotEmpty()
}
fun fromFile(file: File): FileType {
@@ -97,8 +90,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
@@ -167,11 +168,10 @@ class BridgeClient(
Log.d("BridgeClient", "service is dead, restarting")
val canLoad = connect {
Log.e("BridgeClient", "connection failed", it)
context.softRestartApp()
}
if (canLoad != true) {
Log.e("BridgeClient", "failed to reconnect to service, result=$canLoad")
context.softRestartApp()
return@runBlocking
}
}
}
@@ -187,9 +187,6 @@ class BridgeClient(
block()
}.getOrElse {
Log.e("BridgeClient", "service call failed", it)
if (it is DeadObjectException) {
context.softRestartApp()
}
throw it
}
}
@@ -234,10 +231,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

@@ -80,6 +80,7 @@ class FeatureManager(
MessageLogger(),
ConvertMessageLocally(),
SnapchatPlus(),
AdBlockFix(),
DisableMetrics(),
EndpointsBlocker(),
PreventMessageSending(),
@@ -99,6 +100,7 @@ class FeatureManager(
MeoPasscodeBypass(),
AppLock(),
CameraTweaks(),
PerformanceMode(),
InfiniteStoryBoost(),
PinConversations(),
DeviceSpooferHook(),

View File

@@ -2,9 +2,11 @@ package me.eternal.purrfectsnap.core.features
import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.common.data.RuleState
import java.util.concurrent.ConcurrentHashMap
abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleType) : Feature(name) {
private val listeners = mutableListOf<(String, Boolean) -> Unit>()
private val ruleCache = ConcurrentHashMap<String, Boolean>()
fun addStateListener(listener: (conversationId: String, newState: Boolean) -> Unit) {
listeners.add(listener)
@@ -13,18 +15,22 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
open fun getRuleState() = context.config.rules.getRuleState(ruleType)
fun setState(conversationId: String, state: Boolean) {
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
context.bridgeClient.setRule(
context.database.getDMOtherParticipant(conversationId) ?: conversationId,
targetId,
ruleType,
state
)
ruleCache[targetId] = state
listeners.forEach { it(conversationId, state) }
}
fun getState(conversationId: String) =
context.bridgeClient.getRules(
context.database.getDMOtherParticipant(conversationId) ?: conversationId
).contains(ruleType) && getRuleState() != null
fun getState(conversationId: String): Boolean {
val targetId = context.database.getDMOtherParticipant(conversationId) ?: conversationId
return ruleCache.getOrPut(targetId) {
context.bridgeClient.getRules(targetId).contains(ruleType)
} && getRuleState() != null
}
fun canUseRule(conversationId: String): Boolean {
if (ruleType.key == "translation" && context.config.messaging.instantTranslation.globalState != true) {

View File

@@ -37,18 +37,39 @@ 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() },
overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null },
{ true }, isAppExperiment = true)
run {
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() }
val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
val level7Value = { _: ConfigKeyInfo -> 700 }
arrayOf(
"MY_STORY_UPLOAD_QUALITY_LEVEL",
@@ -72,10 +93,14 @@ class ConfigurationOverride : Feature("Configuration Override") {
isForceQuality, { true })
overrideProperty("MEDIA_QUALITY_LEVEL_DOWNGRADING_PERCENTAGE",
isForceQuality, { 0.0f })
overrideProperty("CHAT_MEDIA_IMPORT_TRANSCODED_QUALITY",
isForceQuality, { 4 })
overrideProperty("POSTED_STORY_IMPORT_TRANSCODED_QUALITY",
isForceQuality, { 4 })
}
run {
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() }
val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() || context.config.messaging.galleryMediaSendOverride.mode.getNullable() != null }
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY", isDisableCompression, { 100 })
overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY_V2", isDisableCompression, { 100 })
}
@@ -84,6 +109,53 @@ class ConfigurationOverride : Feature("Configuration Override") {
{ true })
overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() },
{ true })
overrideProperty("ENABLE_MESSAGE_WINDOW_MANAGER", { context.config.global.performanceMode.profile.getNullable() != null },
{ true })
overrideProperty("ENABLE_SIMPLE_CONVERSATION_RESET", { context.config.global.performanceMode.profile.getNullable() == "max" },
{ false })
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",
"SERVER_PREFETCH",
"SERVER_PREFETCH_WITH_COF",
"DISCOVER_FEED_PERFORMANCE",
"LOGIN_PRELOAD",
"PREFETCH_REPO_SUBSCRIBE_ON_CPU",
"COMPUTE_FEED_CACHE_WITH_TTL",
"COMPUTE_FEED_NETWORK_WITH_CACHE",
"OPERA_WARMUP",
"SHOW_PREFETCH",
).forEach { key ->
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() != null }, { true })
}
arrayOf(
"USER_STORY_PRELOAD",
"STARTUP_LENS_ACTIVATOR",
"LENSES_PREVIEW_ACTIVATOR",
"THUMBNAIL_PRESENTER_ACTIVATOR",
"SINGLE_SEGMENT_THUMBNAIL_ACTIVATOR",
"DISCOVER_FEED_STORY_PREFETCH",
"DISCOVER_FEED_THUMBNAILS",
"REFACTORED_WITH_WARMUP_LENS",
).forEach { key ->
overrideProperty(key, { context.config.global.performanceMode.profile.getNullable() == "max" }, { false })
}
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 +187,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 +210,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 +229,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 +255,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")
}
@@ -177,7 +166,29 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
callback = object: DownloadCallback.Stub() {
override fun onSuccess(outputFile: String) {
if (!downloadLogging.contains("success")) return
context.log.verbose("onSuccess: outputFile=$outputFile")
var finalOutputFile = outputFile
runCatching {
val file = java.io.File(outputFile)
if (file.exists()) {
val header = file.inputStream().use { input ->
val buffer = ByteArray(16)
input.read(buffer)
buffer
}
val fileType = FileType.fromByteArray(header)
if (fileType.isVideo && !outputFile.endsWith(".mp4", ignoreCase = true)) {
val newPath = outputFile.removeSuffix(".dat") + ".mp4"
val newFile = java.io.File(newPath)
if (file.renameTo(newFile)) {
finalOutputFile = newPath
context.log.verbose("corrected video extension: $outputFile -> $newPath")
}
}
}
}
context.log.verbose("onSuccess: outputFile=$finalOutputFile")
context.inAppOverlay.showStatusToast(
icon = Icons.Outlined.DownloadDone,
durationMs = 1300,
@@ -267,68 +278,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 +315,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 +359,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 +386,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
@@ -896,74 +707,120 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
context.runOnUiThread {
val selectedChapters = mutableListOf<Int>()
val dialogTranslation = translations.getCategory("dash_dialog")
val tr = context.translation.getCategory("download_processor.dash_dialog")
val chapters = snapChapterList.mapIndexed { index, snapChapter ->
val nextChapter = snapChapterList.getOrNull(index + 1)
val duration = nextChapter?.startTimeMs?.minus(snapChapter.startTimeMs)
SnapChapterInfo(snapChapter.startTimeMs, duration)
}
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply {
setTitle(dialogTranslation["title"])
setMultiChoiceItems(
chapters.map { dialogTranslation.format("segment_text", "from" to prettyPrintTime(it.offset), "to" to prettyPrintTime(it.offset + (it.duration ?: 0))) }.toTypedArray(),
List(chapters.size) { index ->
if (currentChapterIndex == index) {
selectedChapters.add(index)
true
} else false
}.toBooleanArray()
) { _, which, isChecked ->
if (isChecked) {
selectedChapters.add(which)
} else if (selectedChapters.contains(which)) {
selectedChapters.remove(which)
}
}
setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() }
setNeutralButton(dialogTranslation["download_all"]) { _, _ ->
provideDownloadManagerClient(
mediaIdentifier = paramMap["STORY_ID"].toString(),
downloadSource = MediaDownloadSource.PUBLIC_STORY,
mediaAuthor = storyName
).downloadDashMedia(playlistUrl, 0, null)
}
setPositiveButton(this@MediaDownloader.context.translation["button.download"]) { _, _ ->
val groups = mutableListOf<MutableList<SnapChapterInfo>>()
val cancelStr = context.translation["button.cancel"]
val downloadStr = context.translation["button.download"]
var lastChapterIndex = -1
// group consecutive chapters
chapters.forEachIndexed { index, snapChapter ->
lastChapterIndex = if (selectedChapters.contains(index)) {
if (lastChapterIndex == -1) {
groups.add(mutableListOf())
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
val selected = remember { mutableStateListOf<Int>().apply { add(currentChapterIndex) } }
PurrfectGlassCard(
title = tr["title"],
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp))
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
itemsIndexed(chapters) { index, item ->
val label = tr.format("snap_text", "from" to prettyPrintTime(item.offset), "to" to prettyPrintTime(item.offset + (item.duration ?: 0)))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Checkbox(
checked = selected.contains(index),
onCheckedChange = { checked ->
if (checked) selected.add(index) else selected.remove(index)
},
colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)
)
Text(
label,
style = MaterialTheme.typography.bodyMedium,
color = PurrfectOverlayPalette.textPrimary
)
}
}
}
groups.last().add(snapChapter)
index
} else {
-1
}
}
groups.forEach { group ->
val firstChapter = group.first()
val lastChapter = group.last()
val duration = if (firstChapter == lastChapter) {
firstChapter.duration
} else {
lastChapter.duration?.let { lastChapter.offset - firstChapter.offset + it }
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = selected.size == chapters.size,
onCheckedChange = { checked ->
if (checked) {
selected.clear()
selected.addAll(0 until chapters.size)
} else {
selected.clear()
}
},
colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)
)
Text(
tr["download_all"] ?: "Select All",
style = MaterialTheme.typography.bodyMedium,
color = PurrfectOverlayPalette.textPrimary
)
}
provideDownloadManagerClient(
mediaIdentifier = "${paramMap["STORY_ID"]}-${firstChapter.offset}-${lastChapter.offset}",
downloadSource = MediaDownloadSource.PUBLIC_STORY,
mediaAuthor = storyName,
forceAllowDuplicate = forceAllowDuplicate,
).downloadDashMedia(
playlistUrl,
firstChapter.offset.plus(100),
duration
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = { alertDialog.dismiss() },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = PurrfectOverlayPalette.textPrimary)
) {
Text(cancelStr)
}
Button(
onClick = {
val groups = mutableListOf<MutableList<SnapChapterInfo>>()
var lastIdx = -1
chapters.forEachIndexed { index, info ->
if (selected.contains(index)) {
if (lastIdx == -1 || index != lastIdx + 1) groups.add(mutableListOf())
groups.last().add(info)
lastIdx = index
}
}
groups.forEach { group ->
val first = group.first()
val last = group.last()
val duration = if (first == last) first.duration else last.duration?.let { last.offset - first.offset + it }
provideDownloadManagerClient("${paramMap["STORY_ID"]}-${first.offset}", storyName, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate)
.downloadDashMedia(playlistUrl, first.offset.plus(100), duration)
}
alertDialog.dismiss()
},
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary)
) {
Text(downloadStr)
}
}
}
}
}
}.show()
@@ -1063,15 +920,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

@@ -0,0 +1,254 @@
package me.eternal.purrfectsnap.core.features.impl.global
import android.os.SystemClock
import android.view.View
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.ui.dispatchSyntheticTap
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer
import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
import java.util.ArrayList
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
class AdBlockFix : Feature("AdBlockFix") {
private val adConversationIds = Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
@Volatile
private var lastAutoSkippedOperaFingerprint: String? = null
@Volatile
private var lastAutoSkippedOperaAt = 0L
override fun init() {
if (!context.config.global.blockAds.get()) return
hookFeedEntryTracking()
hookMessagingFeedCallbacks()
hookChatFeedRowSuppression()
hookOperaAutoSkip()
}
private fun hookFeedEntryTracking() {
findClass("com.snapchat.client.messaging.FeedEntry").hookConstructor(HookStage.AFTER) { param ->
val feedEntry = param.thisObject<Any>()
val conversationId = feedEntry.getObjectFieldOrNull("mConversationId")?.let(::SnapUUID)?.toString()
?: return@hookConstructor
if (isCampaignFeedEntry(feedEntry) || isChatAdShareFeedEntry(feedEntry)) {
adConversationIds.add(conversationId)
}
}
}
private fun hookMessagingFeedCallbacks() {
context.mappings.useMapper(CallbackMapper::class) {
classLoader = context.androidContext.classLoader
val callbackMap = callbacks.getAsMap().orEmpty()
val hookedCallbacks = mutableSetOf<String>()
fun hookOnce(
callbackClassName: String,
methodName: String,
block: (param: me.eternal.purrfectsnap.core.util.hook.HookAdapter) -> Unit
) {
val hookKey = "$callbackClassName#$methodName"
if (!hookedCallbacks.add(hookKey)) return
runCatching {
findClass(callbackClassName).hook(methodName, HookStage.BEFORE) { param ->
block(param)
}
}.onFailure {
context.log.warn("Failed to hook $methodName on $callbackClassName")
}
}
callbackMap.entries.forEach { (callbackName, callbackClassName) ->
val className = callbackClassName ?: return@forEach
when {
callbackName.startsWith("FetchAndSyncFeed") && callbackName.endsWith("Callback") -> {
hookOnce(className, "onFetchAndSyncFeedComplete") { param ->
val deletedEntries = param.argNullable<ArrayList<Any>>(2)
filterCampaignFeed(param.arg(0), deletedEntries)
if (deletedEntries?.isNotEmpty() == true) {
param.setArg(4, true)
}
}
}
callbackName.contains("SyncFeed") && callbackName.endsWith("Callback") -> {
hookOnce(className, "onSyncFeedComplete") { param ->
filterCampaignFeed(param.arg(0), param.argNullable(2))
}
}
callbackName == "FetchFeedCallback" || callbackName.contains("FetchFeedCallback") -> {
hookOnce(className, "onFetchFeedComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "FetchFeedEntriesCallback" || callbackName.contains("FetchFeedEntriesCallback") -> {
hookOnce(className, "onFetchFeedEntriesComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "QueryFeedCallback" || callbackName.contains("QueryFeedCallback") -> {
hookOnce(className, "onQueryFeedComplete") { param ->
filterCampaignFeed(param.arg(0))
}
}
callbackName == "FeedManagerDelegate" -> {
hookOnce(className, "onFeedEntriesUpdated") { param ->
filterCampaignFeed(param.arg(0))
}
hookOnce(className, "onInternalSyncFeed") { param ->
filterCampaignFeed(param.arg(0))
}
}
}
}
}
}
private fun hookChatFeedRowSuppression() {
context.event.subscribe(BindViewEvent::class) { event ->
val modelDump = event.prevModel.toString()
event.friendFeedItem { conversationId ->
if (adConversationIds.contains(conversationId) || isChatAdShareModel(modelDump)) {
hideBoundChatFeedRow(event.view)
}
}
}
}
private fun hideBoundChatFeedRow(view: View) {
view.hideViewCompletely()
(view.parent as? View)?.hideViewCompletely()
(view.parent?.parent as? View)?.hideViewCompletely()
}
private fun hookOperaAutoSkip() {
onNextActivityCreate {
context.mappings.useMapper(OperaPageViewControllerMapper::class) {
arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName ->
val resolvedMethod = methodName.get() ?: return@forEach
classReference.get()?.hook(resolvedMethod, HookStage.AFTER) { param ->
val viewState = runCatching {
param.thisObject<Any>().getObjectField(viewStateField.get()!!)?.toString()
}.getOrNull() ?: return@hook
if (viewState != "FULLY_DISPLAYED") return@hook
val layerList = runCatching {
param.thisObject<Any>().getObjectField(layerListField.get()!!) as? ArrayList<*>
}.getOrNull() ?: return@hook
val paramMap = runCatching {
layerList.map { Layer(it).paramMap }.firstOrNull()
}.getOrNull() ?: return@hook
if (!isSpotlightCommercialPage(paramMap)) return@hook
val fingerprint = buildOperaFingerprint(paramMap)
val now = SystemClock.elapsedRealtime()
if (fingerprint == lastAutoSkippedOperaFingerprint && now - lastAutoSkippedOperaAt < 1_500L) {
return@hook
}
lastAutoSkippedOperaFingerprint = fingerprint
lastAutoSkippedOperaAt = now
runOnUiThread {
context.mainActivity?.window?.decorView?.postDelayed({
context.mainActivity?.window?.decorView?.let { decorView ->
val x = decorView.width * 0.88f
val y = decorView.height * 0.5f
decorView.dispatchSyntheticTap(x, y)
}
}, 70L)
}
}
}
}
}
}
private fun filterCampaignFeed(entries: ArrayList<Any>, deletedEntries: ArrayList<Any>? = null) {
entries.removeIf { feedEntry ->
if (!isCampaignFeedEntry(feedEntry)) return@removeIf false
val conversationIdInstance = feedEntry.getObjectFieldOrNull("mConversationId") ?: return@removeIf true
deletedEntries?.add(createDeletedFeedEntry(conversationIdInstance))
true
}
}
private fun createDeletedFeedEntry(conversationIdInstance: Any) =
findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
from("mFeedEntryIdentifier") {
set("mConversationId", conversationIdInstance)
}
set("mReason", "AD_CAMPAIGN_COMPLETE")
}!!
private fun isCampaignFeedEntry(feedEntry: Any?): Boolean {
if (feedEntry == null) return false
if (feedEntry.getObjectFieldOrNull("mConversationSubType")?.toString() == "CAMPAIGN") {
return true
}
return feedEntry.getObjectFieldOrNull("mConversationSubTypeMetadata")
?.getObjectFieldOrNull("mCampaignMetadata") != null
}
private fun isChatAdShareFeedEntry(feedEntry: Any): Boolean {
val interactionDump = feedEntry.getObjectFieldOrNull("mInteractionInfo")?.toString().orEmpty()
val displayDump = feedEntry.getObjectFieldOrNull("mDisplayInfo")?.toString().orEmpty()
val combined = "$interactionDump $displayDump"
return isChatAdShareModel(combined)
}
private fun isChatAdShareModel(modelDump: String): Boolean {
if (modelDump.isBlank()) return false
return modelDump.contains("CHAT_AD_SHARE") ||
modelDump.contains("AD_SHARE") ||
modelDump.contains("ChatAd") ||
modelDump.contains("chat_ad_share") ||
modelDump.contains("chat_sponsored_snap") ||
modelDump.contains("CommonAttachmentViewModel") ||
modelDump.contains("visibilityFeedbackURL") ||
modelDump.contains("pageLoadPingURL")
}
private fun isSpotlightCommercialPage(paramMap: ParamMap): Boolean {
val snapSource = paramMap["SNAP_SOURCE"]?.toString()
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY") {
return false
}
val adProductType = paramMap["ad_product_type"]?.toString()
if (!adProductType.isNullOrBlank() && adProductType != "UNKNOWN" && adProductType != "null") {
return true
}
val pageDump = paramMap.toString().uppercase()
return pageDump.contains("COMMERCIAL") || pageDump.contains("PROMOTED_STORY")
}
private fun buildOperaFingerprint(paramMap: ParamMap): String {
val storyId = paramMap["STORY_ID"]?.toString()
val snapId = paramMap["SNAP_ID"]?.toString()
?: paramMap["snap_id"]?.toString()
val index = paramMap["snap_index_in_story"]?.toString()
?: paramMap["SNAP_POSITION_IN_STORY"]?.toString()
return listOfNotNull(storyId, snapId, index, paramMap["ad_product_type"]?.toString()).joinToString("|")
}
}

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

@@ -1,87 +1,244 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.ToneGenerator
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 ToneStep(
val tone: Int,
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 data class ToneSpec(
val sendPattern: List<ToneStep>,
val receivePattern: List<ToneStep>
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 styleSpec(): ToneSpec {
return when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"telegram" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_BEEP, 35),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 45, 25)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 70),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 35, 20)
)
)
"whatsapp" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 55)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 85),
ToneStep(ToneGenerator.TONE_PROP_ACK, 35, 15)
)
)
"subtle" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_PROMPT, 22)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 28)
)
)
else -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_PROMPT, 40),
ToneStep(ToneGenerator.TONE_PROP_BEEP, 28, 18)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 55),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 40, 22)
)
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 playPattern(pattern: List<ToneStep>) {
private fun playBubbleSequence(steps: List<BubbleStep>) {
if (context.isMainActivityPaused) return
context.executeAsync {
var toneGenerator: ToneGenerator? = null
runCatching {
toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 55)
pattern.forEach { step ->
toneGenerator?.startTone(step.tone, step.durationMs)
if (step.pauseAfterMs > 0) delay(step.pauseAfterMs)
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()
}
}
}.also {
runCatching { toneGenerator?.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)
@@ -93,15 +250,14 @@ class ConversationSoundEffects : Feature("Conversation Sound Effects") {
}
override fun init() {
if (!context.config.messaging.conversationSoundEffects.get()) return
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") {
val spec = styleSpec()
playPattern(spec.sendPattern)
playStyledSend()
}
}
@@ -110,7 +266,6 @@ class ConversationSoundEffects : Feature("Conversation Sound Effects") {
if (event.conversationId != activeConversationId) return@subscribe
val myUserId = context.database.myUserId ?: return@subscribe
val spec = styleSpec()
event.messages
.asSequence()
@@ -119,7 +274,7 @@ class ConversationSoundEffects : Feature("Conversation Sound Effects") {
.filter { markSeen(it) }
.firstOrNull()
?.let {
playPattern(spec.receivePattern)
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

@@ -12,6 +12,7 @@ import android.hardware.camera2.CameraCharacteristics.Key
import android.hardware.camera2.CameraManager
import android.media.Image
import android.media.ImageReader
import android.os.Build
import android.util.Range
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -28,6 +29,9 @@ class CameraTweaks : Feature("Camera Tweaks") {
@SuppressLint("MissingPermission", "DiscouragedApi")
override fun init() {
val config = context.config.camera
val skipUnstableStillCaptureTweaks = Build.MANUFACTURER.equals("samsung", ignoreCase = true) ||
Build.HARDWARE.contains("exynos", ignoreCase = true) ||
Build.BRAND.equals("samsung", ignoreCase = true)
// Toggle A: Audio & Video Optimizations (Bitrates)
if (config.audioVideoOptimizations.get()) {
@@ -44,7 +48,7 @@ class CameraTweaks : Feature("Camera Tweaks") {
}
// Toggle B: Camera Optimizations (Hardware ISP - UNSTABLE)
if (config.cameraOptimizations.get()) {
if (config.cameraOptimizations.get() && !skipUnstableStillCaptureTweaks) {
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
val key = param.arg<CaptureRequest.Key<*>>(0)
when (key) {
@@ -121,11 +125,6 @@ class CameraTweaks : Feature("Camera Tweaks") {
param.setArg(1, captureResolutionConfig[1])
}
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
val key = param.arg<CaptureRequest.Key<*>>(0)
if (key == CaptureRequest.CONTROL_ZOOM_RATIO) return@hook
}
CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param ->
val key = param.argNullable<Key<*>>(0) ?: return@hook
@@ -151,6 +150,14 @@ class CameraTweaks : Feature("Camera Tweaks") {
}
}
}
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
val isFrontCamera = param.invokeOriginal(
arrayOf(CameraCharacteristics.LENS_FACING)
) == CameraCharacteristics.LENS_FACING_FRONT
val customFrameRate = (if (isFrontCamera) config.frontCustomFrameRate.getNullable() else config.backCustomFrameRate.getNullable())?.toIntOrNull() ?: return@hook
param.setResult(arrayOf(Range(customFrameRate, customFrameRate)))
}
}
if (config.blackPhotos.get()) {

View File

@@ -0,0 +1,635 @@
package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.animation.ValueAnimator
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.database.sqlite.SQLiteDatabase
import android.media.MediaRecorder
import android.os.HandlerThread
import android.os.Process
import android.util.Base64
import android.util.Range
import android.view.View
import android.widget.OverScroller
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.StaggeredGridLayoutManager
import java.io.File
import java.lang.Thread
import java.lang.reflect.Method
import java.util.LinkedHashMap
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.ThreadPoolExecutor
import com.google.gson.reflect.TypeToken
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import okhttp3.Dispatcher
class PerformanceMode : Feature("Performance Mode") {
companion object {
private const val CHAT_FEED_CACHE_MAX_ROWS = 400
private const val CHAT_FEED_CACHE_MAX_BLOB_BYTES = 512
private const val CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS = 15_000L
private const val MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS = 64
private const val MESSAGE_WINDOW_STATE_MAX_AGE_MS = 7L * 24L * 60L * 60L * 1000L
private const val SNAP_PREFETCH_GROUP_MESSAGES = 48
private const val SNAP_PREFETCH_DM_MESSAGES = 24
private const val REOPEN_WARMUP_GROUP_MESSAGES = 160
private const val REOPEN_WARMUP_DM_MESSAGES = 96
}
private data class SnapshotCell(
val type: Int,
val stringValue: String? = null,
val longValue: Long? = null,
val doubleValue: Double? = null,
val blobValue: String? = null,
)
private data class CursorSnapshot(
val columns: List<String>,
val rows: List<List<SnapshotCell>>,
)
private data class MessageWindowState(
val conversationId: String,
val currentSize: Int,
val oldestOrderKey: Long?,
val newestOrderKey: Long?,
val updatedAt: Long,
val isGroup: Boolean,
)
override fun init() {
val profile = context.config.global.performanceMode.profile.getNullable() ?: return
val isMaxProfile = profile == "max"
val threadPriority = if (isMaxProfile) {
Process.THREAD_PRIORITY_DISPLAY
} else {
Process.THREAD_PRIORITY_MORE_FAVORABLE
}
val minimumFrameRate = if (isMaxProfile) 60 else 45
val minimumRecordingFrameRate = if (isMaxProfile) 30 else 24
val durationScale = if (isMaxProfile) 0.35f else 0.55f
val recyclerViewCacheSize = if (isMaxProfile) 64 else 32
val maxRequests = if (isMaxProfile) 192 else 96
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) 72 else 180
val preferredRefreshRate = if (isMaxProfile) 120f else 90f
val snapMapTransitionDurationMs = if (isMaxProfile) 0L else 24L
val snapMapCameraDurationMs = if (isMaxProfile) 16L else 64L
val snapMapMoveDurationMs = if (isMaxProfile) 8L else 40L
val snapMapPrefetchZoomDelta = if (isMaxProfile) 6 else 3
val preferredJavaThreadPriority = if (isMaxProfile) Thread.NORM_PRIORITY + 2 else Thread.NORM_PRIORITY + 1
context.log.info(
"Performance mode enabled: profile=$profile, threadPriority=$threadPriority, minFps=$minimumFrameRate, minRecordingFps=$minimumRecordingFrameRate, durationScale=$durationScale, rvCache=$recyclerViewCacheSize, maxRequests=$maxRequests/$maxRequestsPerHost, minCoreThreads=$minimumCoreThreads, prefetch=$prefetchItemCount, maxAnimMs=$maxAnimationDurationMs, maxScrollMs=$maxScrollDurationMs, preferredRefreshRate=$preferredRefreshRate, snapMapTransitionMs=$snapMapTransitionDurationMs, snapMapCameraMs=$snapMapCameraDurationMs, snapMapMoveMs=$snapMapMoveDurationMs, snapMapPrefetchZoomDelta=$snapMapPrefetchZoomDelta, javaThreadPriority=$preferredJavaThreadPriority",
"PerformanceMode"
)
runCatching {
ValueAnimator.setFrameDelay(0L)
context.log.info("Applied ValueAnimator frame delay override: 0ms", "PerformanceMode")
}
fun firstHitLogger(name: String): (String) -> Unit {
val didLog = AtomicBoolean(false)
return { details ->
if (didLog.compareAndSet(false, true)) {
context.log.info("First hit: $name | $details", "PerformanceMode")
}
}
}
val handlerThreadConstructorLog = firstHitLogger("HandlerThread.constructor")
val handlerThreadStartLog = firstHitLogger("HandlerThread.start")
val threadStartLog = firstHitLogger("Thread.start")
val executorLog = firstHitLogger("ThreadPoolExecutor.constructor")
val dispatcherLog = firstHitLogger("OkHttp.Dispatcher.constructor")
val animatorLog = firstHitLogger("ValueAnimator.getDurationScale")
val recyclerCtorLog = firstHitLogger("RecyclerView.constructor")
val recyclerAdapterLog = firstHitLogger("RecyclerView.setAdapter")
val recyclerLayoutManagerLog = firstHitLogger("RecyclerView.setLayoutManager")
val sqliteOpenLog = firstHitLogger("SQLiteDatabase.openDatabase")
val sqliteCreateLog = firstHitLogger("SQLiteDatabase.openOrCreateDatabase")
val mediaRecorderLog = firstHitLogger("MediaRecorder.setVideoFrameRate")
val overScrollerLog = firstHitLogger("OverScroller.startScroll")
val mapDialogLog = firstHitLogger("Dialog.show")
val mapViewLog = firstHitLogger("MapView.constructor")
val mapboxNetworkBlockLog = firstHitLogger("SnapMap.telemetryBlock")
val mapCameraAnimLog = firstHitLogger("SnapMap.mapAnimatorDuration")
val mapThreadLog = firstHitLogger("SnapMap.mapThread")
val mapRendererFpsLog = firstHitLogger("SnapMap.mapRendererFps")
val mapTransitionLog = firstHitLogger("SnapMap.transitionOptions")
val mapMoveLog = firstHitLogger("SnapMap.moveDuration")
fun isPerformanceSensitiveThread(name: String?): Boolean {
val normalizedName = name?.lowercase() ?: return false
return listOf("codec", "transcod", "feed", "story", "opera", "messag", "network", "db", "disk", "map", "mapbox", "snapmap", "viewport").any {
normalizedName.contains(it)
}
}
fun clampPositiveDuration(durationMs: Long, maxDurationMs: Long): Long {
if (durationMs <= 0L) return durationMs
return durationMs.coerceAtMost(maxDurationMs)
}
val performanceCacheDir = File(context.androidContext.filesDir, "performance_mode_cache").apply { mkdirs() }
val chatFeedSnapshotFile = File(performanceCacheDir, "chat_feed_snapshot.json")
val lastChatFeedSnapshotWrite = AtomicLong(0L)
val chatFeedSnapshotServedThisProcess = AtomicBoolean(false)
val windowStatePrefs = context.androidContext.getSharedPreferences("purrfectsnap_perf_message_windows", Context.MODE_PRIVATE)
val messageWindowStates = runCatching {
val raw = windowStatePrefs.getString("states", null).orEmpty()
if (raw.isBlank()) {
LinkedHashMap<String, MessageWindowState>()
} else {
context.gson.fromJson<LinkedHashMap<String, MessageWindowState>>(
raw,
object : TypeToken<LinkedHashMap<String, MessageWindowState>>() {}.type
) ?: LinkedHashMap()
}
}.getOrElse { LinkedHashMap() }
fun persistMessageWindowStates() {
runCatching {
windowStatePrefs.edit().putString("states", context.gson.toJson(messageWindowStates)).apply()
}.onFailure {
context.log.error("Failed to persist message window states", it, "PerformanceMode")
}
}
fun isChatFeedQuery(sql: String): Boolean {
val normalized = sql.uppercase()
if (!normalized.startsWith("SELECT")) return false
val hitsFriendsFeedView = sql.contains("FriendsFeedView")
val hitsFeedEntry = sql.contains("feed_entry") && (sql.contains("last_updated_timestamp") || sql.contains("displayInteractionType") || sql.contains("streak_count"))
return (hitsFriendsFeedView || hitsFeedEntry) &&
!normalized.contains("COUNT(") &&
!normalized.contains("SELECT 0") &&
!normalized.contains("WHERE KEY = ?") &&
!normalized.contains("WHERE CLIENT_CONVERSATION_ID = ?")
}
fun cursorCell(cursor: Cursor, index: Int): SnapshotCell {
return when (cursor.getType(index)) {
Cursor.FIELD_TYPE_NULL -> SnapshotCell(Cursor.FIELD_TYPE_NULL)
Cursor.FIELD_TYPE_INTEGER -> SnapshotCell(Cursor.FIELD_TYPE_INTEGER, longValue = cursor.getLong(index))
Cursor.FIELD_TYPE_FLOAT -> SnapshotCell(Cursor.FIELD_TYPE_FLOAT, doubleValue = cursor.getDouble(index))
Cursor.FIELD_TYPE_STRING -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
Cursor.FIELD_TYPE_BLOB -> SnapshotCell(
Cursor.FIELD_TYPE_BLOB,
blobValue = cursor.getBlob(index)
?.takeIf { it.size <= CHAT_FEED_CACHE_MAX_BLOB_BYTES }
?.let { Base64.encodeToString(it, Base64.NO_WRAP) }
)
else -> SnapshotCell(Cursor.FIELD_TYPE_STRING, stringValue = cursor.getString(index))
}
}
fun snapshotFromCursor(cursor: Cursor): CursorSnapshot {
val columns = cursor.columnNames.toList()
val rows = mutableListOf<List<SnapshotCell>>()
if (cursor.moveToFirst()) {
var rowCount = 0
do {
rows += columns.indices.map { index -> cursorCell(cursor, index) }
rowCount++
} while (rowCount < CHAT_FEED_CACHE_MAX_ROWS && cursor.moveToNext())
}
return CursorSnapshot(columns, rows)
}
fun snapshotToMatrixCursor(snapshot: CursorSnapshot): MatrixCursor {
return MatrixCursor(snapshot.columns.toTypedArray(), snapshot.rows.size).also { matrixCursor ->
snapshot.rows.forEach { row ->
matrixCursor.addRow(row.map { cell ->
when (cell.type) {
Cursor.FIELD_TYPE_NULL -> null
Cursor.FIELD_TYPE_INTEGER -> cell.longValue
Cursor.FIELD_TYPE_FLOAT -> cell.doubleValue
Cursor.FIELD_TYPE_BLOB -> cell.blobValue?.let { Base64.decode(it, Base64.NO_WRAP) }
else -> cell.stringValue
}
})
}
}
}
fun readSnapshot(file: File): CursorSnapshot? {
return runCatching {
if (!file.exists()) return null
context.gson.fromJson(file.readText(Charsets.UTF_8), CursorSnapshot::class.java)
}.getOrNull()
}
fun writeSnapshot(file: File, snapshot: CursorSnapshot) {
runCatching {
file.writeText(context.gson.toJson(snapshot), Charsets.UTF_8)
}.onFailure {
context.log.error("Failed to persist friend list snapshot", it, "PerformanceMode")
}
}
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
if (!isMaxProfile) return@subscribe
val url = event.url
if (url.contains("ami/friends")) {
if (chatFeedSnapshotFile.exists()) {
chatFeedSnapshotFile.delete()
context.log.info("Invalidated chat feed snapshot after friends mutation sync", "PerformanceMode")
}
}
if (url.contains("mapbox") && (url.contains("events.") || url.contains("telemetry"))) {
event.canceled = true
mapboxNetworkBlockLog("url=$url")
}
}
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 = preferredJavaThreadPriority
}
threadStartLog("name=${thread.name} priority=${thread.priority}")
if ((thread.name ?: "").contains("map", ignoreCase = true) || (thread.name ?: "").contains("mapbox", ignoreCase = true)) {
mapThreadLog("name=${thread.name} priority=${thread.priority}")
}
}
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")
}
RecyclerView::class.java.hookConstructor(HookStage.AFTER) { param ->
val recyclerView = param.thisObject<RecyclerView>()
recyclerView.setItemViewCacheSize(recyclerViewCacheSize)
recyclerView.overScrollMode = View.OVER_SCROLL_NEVER
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)
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
}
}
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)
val applied = currentRate
.coerceAtLeast(minimumRecordingFrameRate)
.coerceAtMost(if (isMaxProfile) 60 else 45)
if (applied != currentRate) {
param.setArg(0, applied)
}
mediaRecorderLog("requested=$currentRate applied=${param.arg<Int>(0)}")
}
OverScroller::class.java.hook("startScroll", HookStage.BEFORE) { param ->
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)}")
}
}
OverScroller::class.java.hook("fling", HookStage.BEFORE) { param ->
if (param.args().size >= 10) {
val overX = param.arg<Int>(8)
val overY = param.arg<Int>(9)
if (overX != 0) param.setArg(8, 0)
if (overY != 0) param.setArg(9, 0)
}
}
fun applyActivityPerformanceTuning(activity: Activity) {
runCatching {
activity.window.setWindowAnimations(0)
}
}
onNextActivityCreate {
applyActivityPerformanceTuning(it)
}
Dialog::class.java.hook("show", HookStage.AFTER) { param ->
val dialog = param.nullableThisObject<Any>() as? Dialog ?: return@hook
val window = dialog.window ?: return@hook
runCatching {
window.setWindowAnimations(0)
if (dialog::class.java.name.contains("map", ignoreCase = true) || dialog::class.java.name.contains("snap", ignoreCase = true)) {
mapDialogLog("class=${dialog::class.java.name}")
}
}
}
runCatching {
findClass("com.mapbox.mapboxsdk.maps.MapView").hookConstructor(HookStage.AFTER) { param ->
val mapView = param.nullableThisObject<Any>() as? View ?: return@hookConstructor
mapView.overScrollMode = View.OVER_SCROLL_NEVER
mapViewLog("class=${mapView::class.java.name}")
}
}
runCatching {
findClass("com.mapbox.mapboxsdk.maps.renderer.MapRenderer").hook("setMaximumFps", HookStage.BEFORE) { param ->
val requested = param.arg<Int>(0)
val applied = requested.coerceAtLeast(120)
if (applied != requested) {
param.setArg(0, applied)
}
mapRendererFpsLog("requested=$requested applied=${param.arg<Int>(0)}")
}
}
runCatching {
val nativeMapViewClass = findClass("com.mapbox.mapboxsdk.maps.NativeMapView")
val transitionOptionsClass = findClass("com.mapbox.mapboxsdk.style.layers.TransitionOptions")
val transitionOptionsCtor = transitionOptionsClass.getDeclaredConstructor(Long::class.javaPrimitiveType, Long::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).apply {
isAccessible = true
}
fun findNativeMapMethod(name: String, predicate: (Method) -> Boolean): Method? {
return nativeMapViewClass.findRestrictedMethod { method ->
method.name == name && predicate(method)
}?.apply {
isAccessible = true
}
}
val nativeCancelTransitions = findNativeMapMethod("nativeCancelTransitions") { it.parameterCount == 0 }
val nativeSetPrefetchTiles = findNativeMapMethod("nativeSetPrefetchTiles") { it.parameterCount == 1 && it.parameterTypes[0] == Boolean::class.javaPrimitiveType }
val nativeSetPrefetchZoomDelta = findNativeMapMethod("nativeSetPrefetchZoomDelta") { it.parameterCount == 1 && it.parameterTypes[0] == Int::class.javaPrimitiveType }
val nativeSetTransitionDelay = findNativeMapMethod("nativeSetTransitionDelay") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionDuration = findNativeMapMethod("nativeSetTransitionDuration") { it.parameterCount == 1 && it.parameterTypes[0] == Long::class.javaPrimitiveType }
val nativeSetTransitionOptions = findNativeMapMethod("nativeSetTransitionOptions") { it.parameterCount == 1 && it.parameterTypes[0].name == transitionOptionsClass.name }
nativeMapViewClass.hookConstructor(HookStage.AFTER) { param ->
val nativeMapView = param.thisObject<Any>()
runCatching {
nativeSetPrefetchTiles?.invoke(nativeMapView, true)
nativeSetPrefetchZoomDelta?.invoke(nativeMapView, snapMapPrefetchZoomDelta)
nativeSetTransitionDelay?.invoke(nativeMapView, 0L)
nativeSetTransitionDuration?.invoke(nativeMapView, snapMapTransitionDurationMs)
nativeSetTransitionOptions?.invoke(
nativeMapView,
transitionOptionsCtor.newInstance(snapMapTransitionDurationMs, 0L, false)
)
nativeCancelTransitions?.invoke(nativeMapView)
mapTransitionLog("transitionMs=$snapMapTransitionDurationMs prefetchZoomDelta=$snapMapPrefetchZoomDelta placementTransitions=false")
}
}
nativeMapViewClass.findRestrictedMethod { method ->
method.name == "g" &&
method.parameterCount == 6 &&
method.parameterTypes.last() == Long::class.javaPrimitiveType
}?.hook(HookStage.BEFORE) { param ->
val original = param.arg<Long>(5)
val applied = clampPositiveDuration(original, snapMapCameraDurationMs)
if (applied != original) {
param.setArg(5, applied)
}
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
mapCameraAnimLog("requested=$original applied=${param.arg<Long>(5)}")
}
nativeMapViewClass.findRestrictedMethod { method ->
method.name == "v" &&
method.parameterCount == 3 &&
method.parameterTypes[0] == Double::class.javaPrimitiveType &&
method.parameterTypes[1] == Double::class.javaPrimitiveType &&
method.parameterTypes[2] == Long::class.javaPrimitiveType
}?.hook(HookStage.BEFORE) { param ->
val original = param.arg<Long>(2)
val applied = clampPositiveDuration(original, snapMapMoveDurationMs)
if (applied != original) {
param.setArg(2, applied)
}
runCatching { nativeCancelTransitions?.invoke(param.thisObject<Any>()) }
mapMoveLog("requested=$original applied=${param.arg<Long>(2)}")
}
}.onFailure {
context.log.error("Failed to install Snap Map transition hooks", it, "PerformanceMode")
}
runCatching {
findClass("com.snapchat.client.messaging.MessageWindowManager\$CppProxy").hook("initWindow", HookStage.BEFORE) { param ->
if (!isMaxProfile) return@hook
val conversationId = runCatching {
SnapUUID(param.arg(0)).toString()
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return@hook
val initParams = param.arg<Any>(1)
val conversationType = context.database.getConversationType(conversationId) ?: return@hook
val isGroup = conversationType == 1
val savedState = synchronized(messageWindowStates) {
messageWindowStates[conversationId]
?.takeIf { System.currentTimeMillis() - it.updatedAt <= MESSAGE_WINDOW_STATE_MAX_AGE_MS }
}
val enumConstants = initParams.getObjectField("mStartingType")?.javaClass?.enumConstants ?: return@hook
if (savedState != null) {
val restoredMaxSize = if (savedState.isGroup) {
savedState.currentSize.coerceAtLeast(220).coerceAtMost(520)
} else {
savedState.currentSize.coerceAtLeast(140).coerceAtMost(320)
}
val restoredForward = (savedState.currentSize + if (savedState.isGroup) 24 else 16).coerceAtMost(restoredMaxSize)
val restoredBack = if (savedState.isGroup) 180 else 120
initParams.setObjectField("mStartingType", enumConstants.firstOrNull { it.toString() == "MESSAGE" } ?: return@hook)
initParams.setObjectField("mStartingOrderKey", savedState.oldestOrderKey ?: savedState.newestOrderKey)
initParams.setObjectField("mMaxSize", restoredMaxSize)
initParams.setObjectField("mNumMessagesForward", restoredForward)
initParams.setObjectField("mNumMessagesBack", restoredBack)
val warmupAmount = if (savedState.isGroup) REOPEN_WARMUP_GROUP_MESSAGES else REOPEN_WARMUP_DM_MESSAGES
val oldestKey = savedState.oldestOrderKey
if (oldestKey != null) {
context.feature(Messaging::class).conversationManager?.fetchConversationWithMessagesPaginated(
conversationId = conversationId,
lastMessageId = oldestKey,
amount = warmupAmount,
onSuccess = {},
onError = {}
)
}
}
}
}.onFailure {
context.log.error("Failed to install saved message window restore hooks", it, "PerformanceMode")
}
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("MessageWindowManagerDelegate")?.hook("onWindowUpdated", HookStage.AFTER) { param ->
if (!isMaxProfile) return@hook
val conversationId = runCatching { SnapUUID(param.arg(0)).toString() }.getOrNull() ?: return@hook
val update = param.arg<Any>(2)
val pagination = update.getObjectField("mPagination") ?: return@hook
val currentSize = pagination.getObjectField("mCurrentSize") as? Int ?: return@hook
val oldestOrderKey = pagination.getObjectField("mOldestOrderKey") as? Long
val newestOrderKey = pagination.getObjectField("mNewestOrderKey") as? Long
val conversationType = context.database.getConversationType(conversationId) ?: 0
val isGroup = conversationType == 1
synchronized(messageWindowStates) {
messageWindowStates[conversationId] = MessageWindowState(
conversationId = conversationId,
currentSize = currentSize.coerceAtMost(if (isGroup) 420 else 260),
oldestOrderKey = oldestOrderKey,
newestOrderKey = newestOrderKey,
updatedAt = System.currentTimeMillis(),
isGroup = isGroup
)
while (messageWindowStates.size > MESSAGE_WINDOW_STATE_MAX_CONVERSATIONS) {
val eldestKey = messageWindowStates.entries.minByOrNull { it.value.updatedAt }?.key ?: break
messageWindowStates.remove(eldestKey)
}
persistMessageWindowStates()
}
}
}
runCatching {
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.BEFORE) { param ->
if (!isMaxProfile) return@hook
val sql = param.argNullable<String>(1) ?: return@hook
if (!isChatFeedQuery(sql)) return@hook
if (chatFeedSnapshotServedThisProcess.get()) return@hook
readSnapshot(chatFeedSnapshotFile)?.let { snapshot ->
param.setResult(snapshotToMatrixCursor(snapshot))
chatFeedSnapshotServedThisProcess.set(true)
}
}
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.AFTER) { param ->
if (!isMaxProfile) return@hook
val sql = param.argNullable<String>(1) ?: return@hook
if (!isChatFeedQuery(sql)) return@hook
if (chatFeedSnapshotFile.exists()) return@hook
val cursor = param.getResult() as? Cursor ?: return@hook
val now = System.currentTimeMillis()
if (now - lastChatFeedSnapshotWrite.get() < CHAT_FEED_CACHE_MIN_REFRESH_INTERVAL_MS) return@hook
val snapshot = snapshotFromCursor(cursor)
if (snapshot.rows.isEmpty()) return@hook
writeSnapshot(chatFeedSnapshotFile, snapshot)
lastChatFeedSnapshotWrite.set(now)
}
}.onFailure {
context.log.error("Failed to install chat feed cache hooks", it, "PerformanceMode")
}
}
}

View File

@@ -30,6 +30,7 @@ class ConversationManager(
private val fetchConversationWithMessagesMethod by lazy { findMethodByName("fetchConversationWithMessages") }
private val fetchMessageByServerId by lazy { findMethodByName("fetchMessageByServerId") }
private val fetchMessagesByServerIds by lazy { findMethodByName("fetchMessagesByServerIds") }
private val fetchPrefetchableMessagesForConversationsMethod by lazy { findMethodByName("fetchPrefetchableMessagesForConversations") }
private val displayedMessagesMethod by lazy { findMethodByName("displayedMessages") }
private val fetchMessage by lazy { findMethodByName("fetchMessage") }
private val clearConversation by lazy { findMethodByName("clearConversation") }
@@ -163,6 +164,37 @@ class ConversationManager(
)
}
fun fetchPrefetchableMessagesForConversations(
conversationIds: List<String>,
strategyName: String,
messagesPerConversation: Int,
onSuccess: (List<Message>) -> Unit = {},
onError: (error: String) -> Unit = {}
) {
val prefetchRequestClass = fetchPrefetchableMessagesForConversationsMethod.parameterTypes.firstOrNull {
it.name == "com.snapchat.client.messaging.PrefetchRequest"
} ?: error("PrefetchRequest parameter type not found")
val strategyClass = context.androidContext.classLoader.loadClass("com.snapchat.client.messaging.PrefetchStrategy")
val strategy = strategyClass.enumConstants?.firstOrNull { it.toString() == strategyName }
?: error("PrefetchStrategy $strategyName not found")
val prefetchRequest = prefetchRequestClass
.getConstructor(strategyClass, Int::class.javaPrimitiveType)
.newInstance(strategy, messagesPerConversation)
fetchPrefetchableMessagesForConversationsMethod.invoke(
instanceNonNull(),
conversationIds.map { it.toSnapUUID().instanceNonNull() }.toCollection(ArrayList()),
prefetchRequest,
CallbackBuilder(getCallbackClass("FetchMessagesCallback"))
.override("onFetchMessagesComplete") { param ->
onSuccess(param.arg<List<*>>(0).map { Message(it) })
}
.override("onError") {
onError(it.arg<Any>(0).toString())
}.build()
)
}
fun clearConversation(conversationId: String, onSuccess: () -> Unit, onError: (error: String) -> Unit) {
val callback = CallbackBuilder(getCallbackClass("Callback"))
.override("onSuccess") { onSuccess() }

View File

@@ -19,6 +19,14 @@ fun UUID.toBytes(): ByteArray =
class SnapUUID(
private val obj: Any?
) : AbstractWrapper(obj) {
private fun extractUuidBytesFromObject(any: Any): ByteArray? {
runCatching { any.getObjectField("mId") as? ByteArray }.getOrNull()?.let { return it }
runCatching { any.javaClass.getMethod("getId").invoke(any) as? ByteArray }.getOrNull()?.let { return it }
runCatching { any.javaClass.getMethod("getUuid").invoke(any) as? ByteArray }.getOrNull()?.let { return it }
runCatching { any.javaClass.getMethod("uuid").invoke(any) as? ByteArray }.getOrNull()?.let { return it }
return null
}
private val uuidBytes by lazy {
when {
obj is String -> {
@@ -38,6 +46,10 @@ class SnapUUID(
any.getObjectField("mId") as ByteArray
}
}
obj is Any -> {
extractUuidBytesFromObject(obj)
?: runCatching { UUID.fromString(obj.toString()).toBytes() }.getOrElse { ByteArray(16) }
}
else -> ByteArray(16)
}
}

View File

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

View File

@@ -5,10 +5,16 @@ declare var _runtimeName: string;
export const runtimeName = _runtimeName;
let remoteImports: any = null;
try {
remoteImports = require(_runtimeName + "_core/DeviceBridge")?.[_getImportsFunctionName]?.();
} catch {
remoteImports = null;
for (const moduleName of ["DeviceBridge", "Device"]) {
try {
const imports = require(_runtimeName + "_core/" + moduleName)?.[_getImportsFunctionName]?.();
if (imports != null) {
remoteImports = imports;
break;
}
} catch {
// Some Snapchat builds expose DeviceBridge, others only Device.
}
}
function callRemoteFunction(method: string, ...args: any[]): any | null {