4 Commits

Author SHA1 Message Date
particle-box
2135d66124 many fixes & improvements(v1.4.1) 2026-03-12 01:04:30 +05:30
ΞTΞRNAL
481e5840f0 fix overlay position by AhmedRaza 2026-03-11 11:57:12 +05:30
ΞTΞRNAL
216bb71fef feat(PR): Improved Device Spoof by RSR
Improved the spoof device feature
2026-03-11 04:57:16 +05:30
RSR/
181d19424f Improved the spoof device feature 2026-03-11 01:47:07 +04:00
23 changed files with 813 additions and 296 deletions

View File

@@ -30,11 +30,21 @@ class BridgeService : Service() {
private lateinit var remoteSideContext: RemoteSideContext
private var syncCallback: SyncCallback? = null
var messagingBridge: MessagingBridge? = null
@Volatile
private var pendingSocialSnapshotCallback: ((List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit)? = null
private fun clearSyncCallback() {
syncCallback = null
}
fun requestEphemeralSocialSnapshot(callback: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit) {
pendingSocialSnapshotCallback = callback
}
fun clearEphemeralSocialSnapshotRequest() {
pendingSocialSnapshotCallback = null
}
override fun onDestroy() {
clearSyncCallback()
if (::remoteSideContext.isInitialized) {
@@ -216,6 +226,11 @@ class BridgeService : Service() {
remoteSideContext.log.verbose("Received ${groups.size} groups and ${friends.size} friends")
val parsedFriends = friends.mapNotNull { toParcelable<MessagingFriendInfo>(it) }
val parsedGroups = groups.mapNotNull { toParcelable<MessagingGroupInfo>(it) }
pendingSocialSnapshotCallback?.let { callback ->
pendingSocialSnapshotCallback = null
callback(parsedFriends, parsedGroups)
return
}
remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups)
remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups)
}

View File

@@ -119,7 +119,7 @@ class FeaturesRootSection : Routes.Route() {
it.key.dataType.type == DataProcessors.Type.CONTAINER &&
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
) {
containers[it.key.name] = PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>)
containers[it.key.name] = (it.key to it.value).toPropertyPair()
queryContainerRecursive(it.value.get() as ConfigContainer)
}
}
@@ -286,7 +286,7 @@ class FeaturesRootSection : Routes.Route() {
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
)
}.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
}.map { (it.key to it.value).toPropertyPair() }
PropertiesView(
properties = properties,
@@ -1316,7 +1316,7 @@ class FeaturesRootSection : Routes.Route() {
val isActiveSearch = isSearchResults || liveSearchQuery.isNotBlank()
val globalSearchProperties = remember(enableGlobalSearch) {
if (enableGlobalSearch) {
allProperties.filter { isSearchVisibleProperty(it.key) }.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
allProperties.filter { isSearchVisibleProperty(it.key) }.map { (it.key to it.value).toPropertyPair() }
} else {
emptyList()
}
@@ -1537,7 +1537,7 @@ class FeaturesRootSection : Routes.Route() {
) {
PropertiesView(
properties = remember {
configContainer.properties.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }.filter {
configContainer.properties.map { (it.key to it.value).toPropertyPair() }.filter {
!it.key.params.flags.contains(ConfigFlag.HIDDEN)
}
},

View File

@@ -219,19 +219,26 @@ class AddFriendDialog(
var hasFetchError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
context.database.receiveMessagingDataCallback = { friends, groups ->
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else friends
val updateSnapshot: (List<MessagingFriendInfo>, List<MessagingGroupInfo>) -> Unit = { friends, groups ->
coroutineScope.launch {
cachedFriends = friends.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.userId) }
} else friends
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
}
timeoutJob?.cancel()
hasFetchError = false
}
cachedGroups = groups.run {
if (pinnedIds != null) {
sortedBy { -pinnedIds.indexOf(it.conversationId) }
} else groups
}
timeoutJob?.cancel()
hasFetchError = false
}
if (context.bridgeService != null) {
context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot)
} else {
context.database.receiveMessagingDataCallback = updateSnapshot
}
SnapWidgetBroadcastReceiverHelper.create(ReceiversConfig.BRIDGE_SYNC_ACTION) {}.also {
runCatching {
@@ -248,6 +255,13 @@ class AddFriendDialog(
}
}
}
DisposableEffect(Unit) {
onDispose {
timeoutJob?.cancel()
context.bridgeService?.clearEphemeralSocialSnapshotRequest()
context.database.receiveMessagingDataCallback = { _, _ -> }
}
}
me.eternal.purrfectsnap.ui.util.Dialog(
onDismissRequest = {

View File

@@ -6,10 +6,9 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.FEATURE_CONTAINER_ROUTE
import me.eternal.purrfectsnap.ui.manager.pages.features.FeaturesRootSection.Companion.SEARCH_FEATURE_ROUTE
import me.eternal.purrfectsnap.common.config.PropertyKey
import me.eternal.purrfectsnap.common.config.PropertyValue
import me.eternal.purrfectsnap.common.config.PropertyPair
import me.eternal.purrfectsnap.common.config.ConfigContainer
import me.eternal.purrfectsnap.common.config.PropertyPair
import me.eternal.purrfectsnap.common.config.toPropertyPair
@Composable
fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
@@ -36,7 +35,7 @@ fun FeaturesRootSection.AphelionFeaturesScreen(nav: NavBackStackEntry) {
context.translation[it.key.propertyName()].contains(keyword, ignoreCase = true) ||
context.translation[it.key.propertyDescription()].contains(keyword, ignoreCase = true)
)
}.map { PropertyPair(it.key as PropertyKey<Any>, it.value as PropertyValue<Any>) }
}.map { (it.key to it.value).toPropertyPair() }
PropertiesView(
properties = properties,

View File

@@ -1095,7 +1095,6 @@ object LegacyTheme : ThemeContract {
groupList = groups
}
updateScopeLists()
requestLatestSnapshot()
}
DisposableEffect(Unit) {
onDispose {
@@ -1223,6 +1222,7 @@ object LegacyTheme : ThemeContract {
val listState = rememberLazyListState()
var showConfirmDialog by remember { mutableStateOf(false) }
var alsoDeleteFiles by remember { mutableStateOf(false) }
val hapticFeedback = LocalHapticFeedback.current
LaunchedEffect(Unit) {
fetchActiveTasks(this)
@@ -1296,6 +1296,27 @@ object LegacyTheme : ThemeContract {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
TopBarActionButton(
onClick = {
if (context.config.root.global.uiSettings.hapticFeedback.get()) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
mergeSelection(
taskSelection.toList()
.also { taskSelection.clear() }
.map { it.first to it.second!! }
)
},
icon = Icons.Filled.Merge,
text = translation["merge_button"]
)
}
}
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),

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.4.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("280").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.4.1").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("281").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,13 @@
## v1.4.1
- New: Improved device spoofing, now you can create accounts & bypass login issue where login doesn't work(tq to RSR)
- Fix: Story Counter/Story Source position(tq to AhmedRaza)
- Fix: Better Location Overlay Icon for newer Snapchat versions
- Fix: Double tap to mark chat as read for newer Snapchat versions
- New: Mark Chat as Read(Friend feed menu)
- Fix: Opera Download button duplicating
- Fix: All friends getting automatically added even after unselecting in the social tab
- Fix: Spotlight Creator Info
## v1.4.0
- Fix: Download profile picture button showing in all pages
- Fix: Opera Download button & Mark Snaps as seen for newer snapchat versions

View File

@@ -2280,6 +2280,7 @@
"stealth": "\ud83d\udc7b Stealth Mode",
"auto_reply": "\ud83d\udce8 Auto Reply",
"auto_delete_sent_messages": "\ud83d\uddd1\ufe0f Auto Delete Sent Messages",
"mark_chat_as_read": "\ud83d\udcd6 Mark Chat as Read",
"mark_snaps_as_seen": "\ud83d\udc40 Mark Snaps as seen",
"mark_stories_as_seen_locally": "\ud83d\udc40 Mark Stories as seen locally",
"conversation_info": "\ud83d\udc64 Conversation Info",
@@ -2296,11 +2297,23 @@
"schedule_failed": "Scheduled snap failed",
"schedule_cancelled_for": "Cancelled for {name}",
"device_model": {
"samsung_s25_ultra": "Samsung Galaxy S25 Ultra",
"google_pixel_10_pro": "Google Pixel 10 Pro",
"oneplus_13": "OnePlus 13",
"xiaomi_15_ultra": "Xiaomi 15 Ultra",
"null": "Device Default"
"none": "Device Default",
"random": "Random",
"Pixel 8 Pro": "Pixel 8 Pro",
"Pixel 9 Pro XL": "Pixel 9 Pro XL",
"Pixel 10": "Pixel 10",
"Pixel 10 Pro": "Pixel 10 Pro",
"Pixel 10 Pro XL": "Pixel 10 Pro XL",
"Pixel 10 Pro Fold": "Pixel 10 Pro Fold",
"Galaxy S23 Ultra": "Galaxy S23 Ultra",
"Galaxy S24 Ultra": "Galaxy S24 Ultra",
"Galaxy S25 Ultra": "Galaxy S25 Ultra",
"OnePlus 15": "OnePlus 15",
"OnePlus Open": "OnePlus Open",
"Xiaomi 15 Ultra": "Xiaomi 15 Ultra",
"OPPO Find X9 Pro": "OPPO Find X9 Pro",
"vivo X100 Pro": "vivo X100 Pro",
"realme GT 6": "realme GT 6"
},
"settings_menu": {
"default": "Default",
@@ -2754,6 +2767,8 @@
}
},
"friend_menu_option": {
"mark_chat_as_read": "Mark Chat as Read",
"mark_chat_as_read_toast": "Marked chat as read!",
"mark_snaps_as_seen": "Mark Snaps as seen",
"mark_stories_as_seen_locally": "Mark Stories as seen locally",
"preview": "Preview",

View File

@@ -11,6 +11,10 @@ data class PropertyPair<T>(
val name get() = key.name
}
@Suppress("UNCHECKED_CAST")
fun Pair<PropertyKey<*>, PropertyValue<*>>.toPropertyPair(): PropertyPair<Any> =
PropertyPair(first as PropertyKey<Any>, second as PropertyValue<Any>)
enum class FeatureNotice(
val key: String
) {

View File

@@ -19,11 +19,24 @@ class Spoof : ConfigContainer(hasGlobalState = true) {
val spoofDeviceId = container("spoof_device_id", SpoofDeviceIdConfig()) { requireRestart() }
val spoofDevice = boolean("spoof_device") { requireRestart() }
val deviceModel = unique("device_model",
"samsung_s25_ultra",
"google_pixel_10_pro",
"oneplus_13",
"xiaomi_15_ultra"
) {
"none",
"random",
"Pixel 8 Pro",
"Pixel 9 Pro XL",
"Pixel 10",
"Pixel 10 Pro",
"Pixel 10 Pro XL",
"Pixel 10 Pro Fold",
"Galaxy S23 Ultra",
"Galaxy S24 Ultra",
"Galaxy S25 Ultra",
"OnePlus 15",
"OnePlus Open",
"Xiaomi 15 Ultra",
"OPPO Find X9 Pro",
"vivo X100 Pro",
"realme GT 6"
) {
requireRestart()
customOptionTranslationPath = "features.options.device_model"
}

View File

@@ -21,7 +21,7 @@ class UserInterfaceTweaks : ConfigContainer() {
val friendFeedMenuButtons = multiple(
"friend_feed_menu_buttons","conversation_info", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray()
"friend_feed_menu_buttons","conversation_info", "mark_chat_as_read", "mark_snaps_as_seen", "mark_stories_as_seen_locally", *MessagingRuleType.entries.filter { it.showInFriendMenu }.map { it.key }.toTypedArray()
).apply {
set(mutableListOf("conversation_info", MessagingRuleType.STEALTH.key))
}

View File

@@ -125,6 +125,7 @@ class FeatureManager(
PreventForcedLogout(),
ConversationToolbox(),
SpotlightCommentsUsername(),
SpotlightCreatorInfo(),
OperaStoryCounter(),
OperaViewerParamsOverride(),
StealthModeIndicator(),

View File

@@ -67,6 +67,8 @@ import me.eternal.purrfectsnap.core.ui.debugEditText
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.SNAPCHAT_13_80_VERSION
import me.eternal.purrfectsnap.core.util.isSnapchatVersionAtLeast
import me.eternal.purrfectsnap.core.util.media.PreviewUtils
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.core.wrapper.impl.media.MediaInfo
@@ -112,6 +114,12 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
private val translations by lazy {
context.translation.getCategory("download_processor")
}
private val useModernOperaViewerContext by lazy {
isSnapchatVersionAtLeast(
context.mappings.getSnapchatPackageInfo()?.versionName,
SNAPCHAT_13_80_VERSION
)
}
fun provideDownloadManagerClient(
mediaIdentifier: String,
@@ -463,6 +471,19 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
return messageContext
}
private fun resolveLegacyViewerMessageContext(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? {
val parts = paramMap?.get("MESSAGE_ID")
?.toString()
?.split(':')
?.takeIf { it.size == 3 }
?: return null
return OperaViewerMessageContext(
conversationId = parts[0],
clientMessageId = parts[2].toLongOrNull() ?: return null
)
}
private fun parseViewerMessageContext(rawValue: String): OperaViewerMessageContext? {
val parts = rawValue.split(':')
if (parts.size < 3) return null
@@ -480,6 +501,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
fun resolveViewerMessageContextFromParamMap(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? {
if (paramMap == null) return null
if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext(paramMap)
paramMap["MESSAGE_ID"]?.toString()
?.let(::parseViewerMessageContext)
@@ -496,6 +518,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
fun resolveCurrentSnapMessageContext(): OperaViewerMessageContext? {
if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext()
val messaging = context.feature(Messaging::class)
val currentConversationId = messaging.openedConversationUUID?.toString()
val currentMessageId = messaging.lastFocusedMessageId.takeIf { it > 0L }
@@ -855,19 +879,25 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
.toList()
val firstLayerParamMap = layerParamMaps.firstOrNull()
val mediaParamMap: ParamMap = (
// Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work.
val mediaParamMap: ParamMap = if (useModernOperaViewerContext) {
(
// Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work.
layerParamMaps.firstOrNull {
it.containsKey("MESSAGE_ID") &&
(it.containsKey("image_media_info") || it.containsKey("video_media_info_list"))
}
?: firstLayerParamMap?.takeIf {
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
}
?: layerParamMaps.firstOrNull {
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
}
)
} else {
layerParamMaps.firstOrNull {
it.containsKey("MESSAGE_ID") &&
(it.containsKey("image_media_info") || it.containsKey("video_media_info_list"))
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
}
?: firstLayerParamMap?.takeIf {
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
}
?: layerParamMaps.firstOrNull {
it.containsKey("image_media_info") || it.containsKey("video_media_info_list")
}
) ?: return@onOperaViewStateCallback
} ?: return@onOperaViewStateCallback
val mediaInfoMap = mutableMapOf<SplitMediaAssetType, MediaInfo>()
val isVideo = mediaParamMap.containsKey("video_media_info_list")

View File

@@ -211,6 +211,10 @@ class BetterLocation : Feature("Better Location") {
}
val mapViewId = context.resources.getId("mapview")
val statusBarHeight = context.resources.getIdentifier("status_bar_height", "dimen", "android")
.takeIf { it > 0 }
?.let { context.resources.getDimensionPixelSize(it) }
?: 0
if (context.config.global.betterLocation.showBatteryLevel.get()) {
findClass("snap.snap_maps_sdk.nano.SnapMapsSdk\$PublicUserInfo").hook("setDisplayName", HookStage.BEFORE) { param ->
@@ -259,8 +263,8 @@ class BetterLocation : Feature("Better Location") {
}.apply {
layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
addRule(RelativeLayout.ALIGN_PARENT_LEFT)
// Keep the button below the top map chips (Memories/Visited/Popular/Favorites).
setMargins(0, (88 * context.resources.displayMetrics.density).toInt(), 0, 0)
// Keep the button below the map chips and clear the status bar area on taller layouts.
setMargins(0, statusBarHeight + this@BetterLocation.context.userInterface.dpToPx(84), 0, 0)
}
})
}

View File

@@ -0,0 +1,235 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import java.security.SecureRandom
data class DeviceInfo(
val manufacturer: String,
val model: String,
val brand: String,
val device: String,
val product: String,
val hardware: String,
val board: String,
val bootloader: String,
val display: String,
val host: String
)
object DeviceSpoofer {
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 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 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 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 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 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"
),
"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 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 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"
),
"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 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"
),
"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"
),
"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"
),
"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"
),
"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"
)
)
fun getAvailableDevices(): List<String> = devices.keys.toList()
fun getDeviceInfo(modelName: String): DeviceInfo? {
return devices[modelName]
}
fun generateFingerprint(deviceInfo: DeviceInfo, buildVersion: String): String {
val id = "AP3A.${System.currentTimeMillis().toString().take(6)}.005"
val incremental = System.nanoTime().toString().take(8)
return "${deviceInfo.brand}/${deviceInfo.product}/${deviceInfo.device}:$buildVersion/$id/$incremental:user/release-keys"
}
fun generateAndroidId(): String {
val random = SecureRandom()
val bytes = ByteArray(8)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
fun androidIdToBytes(androidId: String): ByteArray {
return try {
val len = androidId.length
val data = ByteArray(len / 2)
var i = 0
while (i < len) {
data[i / 2] = ((Character.digit(androidId[i], 16) shl 4) + Character.digit(androidId[i + 1], 16)).toByte()
i += 2
}
data
} catch (e: Exception) {
ByteArray(8)
}
}
}

View File

@@ -16,113 +16,82 @@ import java.security.SecureRandom
class DeviceSpooferHook: Feature("Device Spoofer") {
private var spoofedAndroidId: String? = null
private var hasLoggedId = false
private var randomizedFingerprints = mutableMapOf<String, String>()
private var spoofedDeviceInfo: DeviceInfo? = null
private var spoofedFingerprint: String? = null
private fun generateAndroidId(): String {
if (spoofedAndroidId != null) return spoofedAndroidId!!
// Always check custom ID first - this ensures changes take effect immediately
val customId = context.config.experimental.spoof.spoofDeviceId.customAndroidId.getNullable()
if (!customId.isNullOrEmpty()) {
spoofedAndroidId = customId.lowercase()
if (!hasLoggedId) {
context.log.info("Using custom Android ID: $spoofedAndroidId")
hasLoggedId = true
val normalizedId = customId.lowercase().trim()
if (normalizedId.length == 16 && normalizedId.all { it in '0'..'9' || it in 'a'..'f' }) {
// Only log when ID actually changes
if (spoofedAndroidId != normalizedId) {
spoofedAndroidId = normalizedId
context.log.info("Using custom Android ID: $spoofedAndroidId")
}
return spoofedAndroidId!!
} else {
context.log.warn("Invalid custom Android ID format (must be 16 hex chars), generating new one")
}
return spoofedAndroidId!!
}
// No custom ID set - use stored or generate new one
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
spoofedAndroidId = sharedPrefs.getString("android_id", null)
if (spoofedAndroidId == null) {
spoofedAndroidId = generateRandomHexString(16)
val storedId = sharedPrefs.getString("android_id", null)
if (storedId == null || storedId.length != 16) {
spoofedAndroidId = DeviceSpoofer.generateAndroidId()
sharedPrefs.edit().putString("android_id", spoofedAndroidId).apply()
context.log.info("Generated new Android ID: $spoofedAndroidId")
} else if (!hasLoggedId) {
context.log.info("Using stored Android ID: $spoofedAndroidId")
} else {
// Only use cached value if it matches stored value (handles regeneration)
if (spoofedAndroidId != storedId) {
spoofedAndroidId = storedId
context.log.info("Using stored Android ID: $spoofedAndroidId")
}
}
hasLoggedId = true
return spoofedAndroidId!!
}
private fun generateRandomHexString(length: Int): String {
val random = SecureRandom()
val bytes = ByteArray(length / 2)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
private fun getDeviceInfo(modelName: String): DeviceInfo? {
return DeviceSpoofer.getDeviceInfo(modelName)
}
private fun randomizeFingerprintBuildNumber(fingerprint: String, deviceKey: String): String {
if (randomizedFingerprints.containsKey(deviceKey)) {
return randomizedFingerprints[deviceKey]!!
}
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
val savedFingerprint = sharedPrefs.getString("fingerprint_$deviceKey", null)
if (savedFingerprint != null) {
randomizedFingerprints[deviceKey] = savedFingerprint
return savedFingerprint
}
val parts = fingerprint.split("/")
if (parts.size >= 3) {
val buildPart = parts[2]
val buildSections = buildPart.split(":")
if (buildSections.size >= 2) {
val buildDetails = buildSections[1].split("/")
if (buildDetails.size >= 2) {
val randomBuildNumber = generateRandomBuildNumber()
val newBuildDetails = buildDetails.toMutableList()
newBuildDetails[1] = randomBuildNumber
val newBuildPart = "${buildSections[0]}:${newBuildDetails.joinToString("/")}"
val newFingerprint = "${parts[0]}/${parts[1]}/$newBuildPart"
randomizedFingerprints[deviceKey] = newFingerprint
sharedPrefs.edit().putString("fingerprint_$deviceKey", newFingerprint).apply()
return newFingerprint
}
private fun getSpoofedDeviceInfo(): DeviceInfo? {
val selectedModel = context.config.experimental.spoof.deviceModel.getNullable() ?: return null
if (selectedModel == "random") {
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
val randomDevice = sharedPrefs.getString("random_device", null)
if (randomDevice == null) {
val availableDevices = DeviceSpoofer.getAvailableDevices()
val newRandomDevice = availableDevices.random()
sharedPrefs.edit().putString("random_device", newRandomDevice).apply()
context.log.info("Randomly selected device: $newRandomDevice")
spoofedDeviceInfo = getDeviceInfo(newRandomDevice)
} else {
context.log.info("Using stored random device: $randomDevice")
spoofedDeviceInfo = getDeviceInfo(randomDevice)
}
return spoofedDeviceInfo
}
randomizedFingerprints[deviceKey] = fingerprint
return fingerprint
if (selectedModel == "none" || selectedModel == "null") return null
spoofedDeviceInfo = getDeviceInfo(selectedModel)
return spoofedDeviceInfo
}
private fun generateRandomBuildNumber(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
val random = SecureRandom()
return (1..10).map { chars[random.nextInt(chars.length)] }.joinToString("")
}
private fun randomizeDisplayId(display: String, deviceKey: String, buildNumber: String): String {
private fun getSpoofedFingerprint(deviceInfo: DeviceInfo): String {
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
val savedDisplay = sharedPrefs.getString("display_$deviceKey", null)
if (savedDisplay != null) return savedDisplay
val parts = display.split(".")
val newDisplay = if (parts.size > 1) {
"${parts[0]}.${parts[1]}.$buildNumber"
val storedFingerprint = sharedPrefs.getString("device_fingerprint", null)
if (storedFingerprint == null) {
val buildVersion = Build.VERSION.RELEASE
spoofedFingerprint = DeviceSpoofer.generateFingerprint(deviceInfo, buildVersion)
sharedPrefs.edit().putString("device_fingerprint", spoofedFingerprint).apply()
context.log.info("Generated new device fingerprint: $spoofedFingerprint")
} else {
display
spoofedFingerprint = storedFingerprint
context.log.info("Using stored device fingerprint: $spoofedFingerprint")
}
sharedPrefs.edit().putString("display_$deviceKey", newDisplay).apply()
return newDisplay
}
private fun getRandomSerial(deviceKey: String): String {
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
val savedSerial = sharedPrefs.getString("serial_$deviceKey", null)
if (savedSerial != null) return savedSerial
val random = SecureRandom()
val serial = (1..16).map {
"0123456789ABCDEF"[random.nextInt(16)]
}.joinToString("")
sharedPrefs.edit().putString("serial_$deviceKey", serial).apply()
return serial
}
private fun getRandomBuildId(deviceKey: String): String {
val sharedPrefs = context.androidContext.getSharedPreferences("purrfectsnap_spoof", 0)
val savedBuildId = sharedPrefs.getString("build_id_$deviceKey", null)
if (savedBuildId != null) return savedBuildId
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
val random = SecureRandom()
val buildId = (1..8).map { chars[random.nextInt(chars.length)] }.joinToString("")
sharedPrefs.edit().putString("build_id_$deviceKey", buildId).apply()
return buildId
return spoofedFingerprint!!
}
private fun getRandomGsfId(): String {
@@ -165,65 +134,6 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
return mac
}
data class DeviceProfile(
val manufacturer: String,
val brand: String,
val model: String,
val device: String,
val product: String,
val hardware: String,
val board: String,
val fingerprint: String,
val display: String
)
private val deviceProfiles = mapOf(
"samsung_s25_ultra" to DeviceProfile(
manufacturer = "samsung",
brand = "samsung",
model = "SM-S938U",
device = "e3q",
product = "e3qsqw",
hardware = "qcom",
board = "taro",
fingerprint = "samsung/e3qsqw/e3q:15/AP2A.240805.005/S938USQU1AXL2:user/release-keys",
display = "AP2A.240805.005.S938USQU1AXL2"
),
"google_pixel_10_pro" to DeviceProfile(
manufacturer = "Google",
brand = "google",
model = "Pixel 10 Pro",
device = "caiman",
product = "caiman",
hardware = "caiman",
board = "caiman",
fingerprint = "google/caiman/caiman:15/AP2A.240805.005/12345678:user/release-keys",
display = "AP2A.240805.005"
),
"oneplus_13" to DeviceProfile(
manufacturer = "OnePlus",
brand = "OnePlus",
model = "CPH2649",
device = "OP5B41L1",
product = "CPH2649_EEA",
hardware = "qcom",
board = "kalama",
fingerprint = "OnePlus/CPH2649_EEA/OP5B41L1:15/SKQ1.240805.001/1730123456789:user/release-keys",
display = "CPH2649_15.0.0.300(EX01)"
),
"xiaomi_15_ultra" to DeviceProfile(
manufacturer = "Xiaomi",
brand = "Xiaomi",
model = "23127PN0CC",
device = "aurora",
product = "aurora_global",
hardware = "qcom",
board = "taro",
fingerprint = "Xiaomi/aurora_global/aurora:14/UKQ1.231003.002/V816.0.7.0.UMLMIXM:user/release-keys",
display = "UKQ1.231003.002"
)
)
private fun hookInstallerPackageName() {
context.androidContext.packageManager::class.java.hook("getInstallerPackageName", HookStage.BEFORE) { param ->
param.setResult("com.android.vending")
@@ -232,6 +142,15 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
@SuppressLint("MissingPermission")
override fun init() {
val spoofDevice by context.config.experimental.spoof.spoofDevice
if (spoofDevice) {
val deviceInfo = getSpoofedDeviceInfo()
if (deviceInfo != null) {
getSpoofedFingerprint(deviceInfo)
context.log.info("Device spoofing initialized: ${deviceInfo.manufacturer} ${deviceInfo.model}")
}
}
if (LSPatchUpdater.HAS_LSPATCH) {
hookInstallerPackageName()
}
@@ -380,66 +299,62 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
}
}
val spoofDevice by context.config.experimental.spoof.spoofDevice
if (spoofDevice) {
val selectedDevice = context.config.experimental.spoof.deviceModel.getNullable() ?: "samsung_s25_ultra"
val deviceProfile = deviceProfiles[selectedDevice] ?: deviceProfiles["samsung_s25_ultra"]!!
val randomizedFingerprint = randomizeFingerprintBuildNumber(deviceProfile.fingerprint, selectedDevice)
val buildNumber = randomizedFingerprint.split("/").getOrNull(2)?.split(":")?.getOrNull(1)?.split("/")?.getOrNull(1) ?: generateRandomBuildNumber()
val randomizedDisplay = randomizeDisplayId(deviceProfile.display, selectedDevice, buildNumber)
val randomSerial = getRandomSerial(selectedDevice)
val randomBuildId = getRandomBuildId(selectedDevice)
context.log.info("Spoofing device as: ${deviceProfile.model}")
val deviceInfo = getSpoofedDeviceInfo()
if (deviceInfo != null) {
val fingerprint = getSpoofedFingerprint(deviceInfo)
Build::class.java.apply {
fields.forEach { field ->
if (!field.isAccessible) field.isAccessible = true
runCatching {
val modifiersField = java.lang.reflect.Field::class.java.getDeclaredField("modifiers")
modifiersField.isAccessible = true
modifiersField.setInt(field, field.modifiers and java.lang.reflect.Modifier.FINAL.inv())
}
when (field.name) {
"MANUFACTURER" -> field.set(null, deviceProfile.manufacturer)
"BRAND" -> field.set(null, deviceProfile.brand)
"MODEL" -> field.set(null, deviceProfile.model)
"DEVICE" -> field.set(null, deviceProfile.device)
"PRODUCT" -> field.set(null, deviceProfile.product)
"HARDWARE" -> field.set(null, deviceProfile.hardware)
"BOARD" -> field.set(null, deviceProfile.board)
"FINGERPRINT" -> field.set(null, randomizedFingerprint)
"DISPLAY" -> field.set(null, randomizedDisplay)
"SERIAL" -> field.set(null, randomSerial)
"ID" -> field.set(null, randomBuildId)
"TAGS" -> field.set(null, "release-keys")
"TYPE" -> field.set(null, "user")
"USER" -> field.set(null, "android-build")
"HOST" -> field.set(null, "build-host")
context.log.info("Device spoofing active: ${deviceInfo.manufacturer} ${deviceInfo.model}")
Build::class.java.apply {
fields.forEach { field ->
if (!field.isAccessible) field.isAccessible = true
runCatching {
val modifiersField = java.lang.reflect.Field::class.java.getDeclaredField("modifiers")
modifiersField.isAccessible = true
modifiersField.setInt(field, field.modifiers and java.lang.reflect.Modifier.FINAL.inv())
}
when (field.name) {
"MANUFACTURER" -> field.set(null, deviceInfo.manufacturer)
"MODEL" -> field.set(null, deviceInfo.model)
"BRAND" -> field.set(null, deviceInfo.brand)
"DEVICE" -> field.set(null, deviceInfo.device)
"PRODUCT" -> field.set(null, deviceInfo.product)
"HARDWARE" -> field.set(null, deviceInfo.hardware)
"FINGERPRINT" -> field.set(null, fingerprint)
"BOARD" -> try { field.set(null, deviceInfo.board) } catch (_: Exception) {}
"BOOTLOADER" -> try { field.set(null, deviceInfo.bootloader) } catch (_: Exception) {}
"DISPLAY" -> try { field.set(null, deviceInfo.display) } catch (_: Exception) {}
"HOST" -> try { field.set(null, deviceInfo.host) } catch (_: Exception) {}
"TIME" -> try {
val currentTime = System.currentTimeMillis()
val randomDaysAgo = (30..180).random()
val buildTime = currentTime - (randomDaysAgo * 24L * 60L * 60L * 1000L)
field.setLong(null, buildTime)
} catch (_: Exception) {}
}
}
}
}
findClass("android.os.SystemProperties").apply {
hook("get", HookStage.BEFORE) { param ->
val key = param.arg<String>(0)
when (key) {
"ro.product.manufacturer", "ro.product.vendor.manufacturer", "ro.product.system.manufacturer", "ro.product.odm.manufacturer" -> param.setResult(deviceProfile.manufacturer)
"ro.product.brand", "ro.product.vendor.brand", "ro.product.system.brand", "ro.product.odm.brand" -> param.setResult(deviceProfile.brand)
"ro.product.model", "ro.product.vendor.model", "ro.product.system.model", "ro.product.odm.model" -> param.setResult(deviceProfile.model)
"ro.product.device", "ro.product.vendor.device", "ro.product.system.device", "ro.product.odm.device" -> param.setResult(deviceProfile.device)
"ro.product.name", "ro.product.vendor.name", "ro.product.system.name", "ro.product.odm.name" -> param.setResult(deviceProfile.product)
"ro.hardware", "ro.hardware.chipname" -> param.setResult(deviceProfile.hardware)
"ro.product.board" -> param.setResult(deviceProfile.board)
"ro.build.fingerprint" -> param.setResult(randomizedFingerprint)
"ro.build.display.id" -> param.setResult(randomizedDisplay)
"ro.serialno", "ro.boot.serialno", "ril.serialnumber" -> param.setResult(randomSerial)
"ro.build.id" -> param.setResult(randomBuildId)
"ro.build.tags" -> param.setResult("release-keys")
"ro.build.type" -> param.setResult("user")
"ro.build.user" -> param.setResult("android-build")
"ro.build.host" -> param.setResult("build-host")
runCatching {
findClass("android.os.SystemProperties").hook("get", HookStage.BEFORE) { param ->
val key = param.argNullable<String>(0) ?: return@hook
when (key) {
"ro.product.manufacturer" -> param.setResult(deviceInfo.manufacturer)
"ro.product.model" -> param.setResult(deviceInfo.model)
"ro.product.brand" -> param.setResult(deviceInfo.brand)
"ro.product.device" -> param.setResult(deviceInfo.device)
"ro.product.name" -> param.setResult(deviceInfo.product)
"ro.product.board" -> param.setResult(deviceInfo.board)
"ro.hardware" -> param.setResult(deviceInfo.hardware)
"ro.build.fingerprint" -> param.setResult(fingerprint)
"ro.bootloader" -> param.setResult(deviceInfo.bootloader)
"ro.build.display.id" -> param.setResult(deviceInfo.display)
}
}
context.log.info("SystemProperties hooks installed successfully")
}.onFailure {
context.log.warn("Failed to hook SystemProperties: ${it.message}")
}
}
}

View File

@@ -37,10 +37,10 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.FileType
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.common.util.ktx.getLongOrNull
import me.eternal.purrfectsnap.common.util.ktx.getTypeArguments
import me.eternal.purrfectsnap.common.data.FileType
import me.eternal.purrfectsnap.core.event.events.impl.ActivityResultEvent
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
@@ -108,7 +108,7 @@ class MediaFilePicker : Feature("Media File Picker") {
val handlerParamMethod = contextType.methods.firstOrNull { method ->
method.parameterTypes.size == 1 && (
method.parameterTypes[0].name.endsWith("ChatMediaDrawerActionHandler") ||
actionHandlerCls.isAssignableFrom(method.parameterTypes[0])
actionHandlerCls.isAssignableFrom(method.parameterTypes[0])
)
} ?: return@useMapper
val sendItems = handlerParamMethod.parameterTypes[0].methods.firstOrNull { it.name == sendItemsName } ?: return@useMapper
@@ -127,7 +127,7 @@ class MediaFilePicker : Feature("Media File Picker") {
val uri = param.arg<Uri>(0)
if (!uri.toString().endsWith(firstVideoId.toString())) return@hook
param.setResult(object: CursorWrapper(param.getResult() as Cursor) {
param.setResult(object : CursorWrapper(param.getResult() as Cursor) {
override fun getLong(columnIndex: Int): Long {
if (getColumnName(columnIndex) == "duration") {
return lastMediaDuration ?: -1
@@ -269,7 +269,7 @@ class MediaFilePicker : Feature("Media File Picker") {
context.event.subscribe(AddViewEvent::class) { event ->
if (event.parent !is FrameLayout || drawerViewClass?.isInstance(event.view) != true) return@subscribe
event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
event.view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
if (event.parent.findViewWithTag<View>(buttonTag)?.run {
visibility = View.VISIBLE
@@ -345,6 +345,7 @@ class MediaFilePicker : Feature("Media File Picker") {
}
)
}
override fun onViewDetachedFromWindow(v: View) {
event.parent.findViewWithTag<View>(buttonTag)?.visibility = View.GONE
}
@@ -352,5 +353,4 @@ class MediaFilePicker : Feature("Media File Picker") {
}
}
}
}

View File

@@ -1,66 +1,173 @@
package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.os.SystemClock
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessageUpdate
import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard
import me.eternal.purrfectsnap.common.util.ktx.findFieldsToString
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.AutoMarkAsRead
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
import me.eternal.purrfectsnap.core.ui.getValdiContext
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
import me.eternal.purrfectsnap.mapper.impl.ChatEventDispatcherMapper
class DoubleTapChatAction: Feature("Double Tap Chat Action") {
private data class ChatDoubleTapTarget(
val conversationId: String,
val messageId: Long
)
private val messageIdPattern = Regex("([0-9a-fA-F-]{36}):[^,\\s:]+:(\\d+)")
private var lastTapTarget: ChatDoubleTapTarget? = null
private var lastTapAt = 0L
private var lastTapDownTime = -1L
private var lastHandledTarget: ChatDoubleTapTarget? = null
private var lastHandledAt = 0L
private fun resolveTarget(rawValue: String?): ChatDoubleTapTarget? {
val match = rawValue?.let { messageIdPattern.find(it) } ?: return null
val conversationId = match.groupValues[1]
val messageId = match.groupValues[2].toLongOrNull() ?: return null
val message = context.database.getConversationMessageFromId(messageId) ?: return null
if (message.clientConversationId != conversationId) return null
return ChatDoubleTapTarget(conversationId, messageId)
}
private fun resolveTargetFromDispatcherEvent(event: Any): ChatDoubleTapTarget? {
resolveTarget(event.toString())?.let { return it }
val field = event.javaClass.findFieldsToString(event, once = true) { _, value ->
value.contains("ChatViewModel") || messageIdPattern.containsMatchIn(value)
}.firstOrNull() ?: return null
return resolveTarget(field.get(event)?.toString())
}
private fun resolveTargetFromView(view: View): ChatDoubleTapTarget? {
val valdiContext = view.getValdiContext() ?: return null
return sequenceOf(
valdiContext.viewModel,
valdiContext.viewModelLegacy,
valdiContext.componentContext?.get()
).mapNotNull { candidate ->
resolveTarget(candidate?.toString())
}.firstOrNull()
}
private fun executeAction(action: String, target: ChatDoubleTapTarget) {
if (
lastHandledTarget == target &&
SystemClock.uptimeMillis() - lastHandledAt <= ViewConfiguration.getDoubleTapTimeout().toLong()
) {
return
}
lastHandledTarget = target
lastHandledAt = SystemClock.uptimeMillis()
if (action == "like_message") {
context.feature(Messaging::class).conversationManager?.reactToMessage(
target.conversationId,
target.messageId,
intentionType = 1L,
onError = {},
onSuccess = {}
)
}
if (action == "copy_text") {
val messageContent = context.database.getConversationMessageFromId(target.messageId)?.messageContent ?: return
val proto = ProtoReader(messageContent).followPath(4, 4) ?: return
context.androidContext.copyToClipboard(
proto.getBuffer().getMessageText(ContentType.fromMessageContainer(proto) ?: ContentType.CHAT) ?: return,
"Chat Message"
)
}
if (action == "delete_message") {
context.feature(Messaging::class).conversationManager?.updateMessage(
target.conversationId,
target.messageId,
MessageUpdate.ERASE,
onResult = {}
)
}
if (action == "mark_as_read") {
val message = context.database.getConversationMessageFromId(target.messageId) ?: return
when (ContentType.fromId(message.contentType)) {
ContentType.SNAP,
ContentType.TINY_SNAP,
ContentType.EXTERNAL_MEDIA -> {
context.coroutineScope.launch {
context.feature(AutoMarkAsRead::class).markSnapAsSeen(target.conversationId, target.messageId)
}
}
else -> {
context.feature(StealthMode::class).addDisplayedMessageException(target.messageId)
context.feature(Messaging::class).conversationManager?.displayedMessages(
target.conversationId,
target.messageId,
onResult = {
if (it != null) {
context.log.error("Failed to mark conversation as read: $it")
context.shortToast(context.translation["toast_mark_conversation_read_failed"])
}
}
)
}
}
}
if (action == "custom_emoji_reaction") {
context.feature(Messaging::class).conversationManager?.reactToMessage(
target.conversationId,
target.messageId,
emoji = context.config.messaging.doubleTapChatActionCustomEmoji.getNullable()?.takeIf { it.isNotEmpty() } ?: "\uD83D\uDC4D",
onError = {},
onSuccess = {}
)
}
}
override fun init() {
var action = context.config.messaging.doubleTapChatAction.getNullable() ?: return
context.mappings.useMapper(ChatEventDispatcherMapper::class) {
classReference.getAsClass()?.hook("onChatItemDoubleClickEvent", HookStage.BEFORE) { param ->
param.setResult(null)
val event = param.arg<Any>(0)
val viewModel = event.javaClass.findFieldsToString(event, once = true) { field, value -> value.contains("ChatViewModel") }.firstOrNull()?.get(event)?.toString() ?: return@hook
val (conversationId, _, clientMessageId) = viewModel.substringAfter("messageId=").substringBefore(",").split(":").takeIf { it.size == 3 } ?: return@hook
val messageId = clientMessageId.toLongOrNull() ?: return@hook
if (action == "like_message") {
context.feature(Messaging::class).conversationManager?.reactToMessage(
conversationId,
messageId,
intentionType = 1L,
onError = {},
onSuccess = {}
)
}
if (action == "copy_text") {
var messageContent = context.database.getConversationMessageFromId(messageId)?.messageContent ?: return@hook
var proto = ProtoReader(messageContent).followPath(4, 4) ?: return@hook
context.androidContext.copyToClipboard(proto.getBuffer().getMessageText(ContentType.fromMessageContainer(proto) ?: ContentType.CHAT) ?: return@hook, "Chat Message")
}
if (action == "delete_message" || action == "mark_as_read") {
context.feature(Messaging::class).conversationManager?.updateMessage(
conversationId,
messageId,
if (action == "delete_message") MessageUpdate.ERASE else MessageUpdate.READ,
onResult = {}
)
}
if (action == "custom_emoji_reaction") {
context.feature(Messaging::class).conversationManager?.reactToMessage(
conversationId,
messageId,
emoji = context.config.messaging.doubleTapChatActionCustomEmoji.getNullable()?.takeIf { it.isNotEmpty() } ?: "\uD83D\uDC4D",
onError = {},
onSuccess = {}
)
}
resolveTargetFromDispatcherEvent(param.arg(0))?.let { executeAction(action, it) }
}
}
View::class.java.hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
val motionEvent = param.arg<MotionEvent>(0)
if (motionEvent.actionMasked != MotionEvent.ACTION_UP) return@hook
if (motionEvent.eventTime - motionEvent.downTime > ViewConfiguration.getTapTimeout()) return@hook
if (lastTapDownTime == motionEvent.downTime) return@hook
val target = resolveTargetFromView(param.thisObject()) ?: return@hook
val now = SystemClock.uptimeMillis()
val isSecondTap = lastTapTarget == target &&
now - lastTapAt <= ViewConfiguration.getDoubleTapTimeout().toLong()
lastTapDownTime = motionEvent.downTime
if (isSecondTap) {
executeAction(action, target)
lastTapTarget = null
lastTapAt = 0L
return@hook
}
lastTapTarget = target
lastTapAt = now
}
}
}
}

View File

@@ -113,9 +113,14 @@ class OperaStoryCounter : Feature("OperaStoryCounter") {
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.TOP or Gravity.END
val isOperaDownloadEnabled = this@OperaStoryCounter.context.config.downloader.operaDownloadButton.get()
gravity = Gravity.TOP or if (isOperaDownloadEnabled) Gravity.START else Gravity.END
topMargin = this@OperaStoryCounter.context.userInterface.dpToPx(50)
marginEnd = this@OperaStoryCounter.context.userInterface.dpToPx(10)
if (isOperaDownloadEnabled) {
marginStart = this@OperaStoryCounter.context.userInterface.dpToPx(10)
} else {
marginEnd = this@OperaStoryCounter.context.userInterface.dpToPx(10)
}
}
}
viewGroup.addView(composeView)

View File

@@ -55,6 +55,7 @@ import me.eternal.purrfectsnap.core.features.impl.experiments.EndToEndEncryption
import me.eternal.purrfectsnap.core.features.impl.messaging.AutoMarkAsRead
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.ui.children
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
@@ -649,6 +650,41 @@ class FriendFeedInfoMenu : AbstractMenu() {
)
}
if (friendFeedMenuOptions.contains("mark_chat_as_read")) {
MenuElement(
remember { elementIndex++ },
Icons.Outlined.MarkChatRead,
translation["mark_chat_as_read"],
onClick = {
context.apply {
closeMenu()
val latestMessageId = database.getMessagesFromConversationId(conversationId, 1)
?.firstOrNull()
?.clientMessageId
?.toLong()
?: return@apply
feature(StealthMode::class).addDisplayedMessageException(latestMessageId)
feature(Messaging::class).conversationManager?.displayedMessages(
conversationId,
latestMessageId
) { error ->
if (error != null) {
log.error("Failed to mark chat as read: $error")
shortToast(this.translation["toast_mark_conversation_read_failed"])
} else {
inAppOverlay.showStatusToast(
Icons.Default.Info,
translation["mark_chat_as_read_toast"],
durationMs = 1800
)
}
}
}
}
)
}
if (targetUser != null && friendFeedMenuOptions.contains("mark_stories_as_seen_locally")) {
val markAsSeenTranslation = remember { context.translation.getCategory("mark_as_seen") }

View File

@@ -38,9 +38,11 @@ import me.eternal.purrfectsnap.core.ui.iterateParent
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.ui.randomTag
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
import me.eternal.purrfectsnap.core.util.SNAPCHAT_13_80_VERSION
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.isSnapchatVersionAtLeast
import me.eternal.purrfectsnap.core.util.ktx.vibrateLongPress
import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper
@@ -54,8 +56,15 @@ class OperaViewerIcons : AbstractMenu() {
private val inlineMarkButtonVisibleState = mutableStateOf(false)
private var overlayRegistered = false
private var hooksInitialized = false
private val useModernViewerBehavior by lazy {
isSnapchatVersionAtLeast(
context.mappings.getSnapchatPackageInfo()?.versionName,
SNAPCHAT_13_80_VERSION
)
}
override fun init() {
if (!useModernViewerBehavior) return
if (hooksInitialized) return
hooksInitialized = true
@@ -205,6 +214,18 @@ class OperaViewerIcons : AbstractMenu() {
}
override fun onViewAdded(event: AddViewEvent) {
if (!useModernViewerBehavior) {
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
val viewGroup = event.view as? ViewGroup ?: return
if (
viewGroup.childCount == 0 ||
viewGroup.children().any { it !is ImageView } ||
event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }
) return
inject(viewGroup)
}
return
}
if (!shouldInjectIntoViewer(event)) return
val viewGroup = event.view as? ViewGroup ?: return
viewGroup.setTag(injectedParentTag, true)
@@ -276,6 +297,45 @@ class OperaViewerIcons : AbstractMenu() {
}
if (context.config.messaging.markSnapAsSeenButton.get()) {
if (!useModernViewerBehavior) {
parent.addView(createComposeView(parent.context) {
Icon(
imageVector = Icons.Default.RemoveRedEye,
tint = Color.White,
contentDescription = null
)
}.apply {
setOnClickListener {
this@OperaViewerIcons.context.coroutineScope.launch {
markCurrentSnapAsSeen(parent)
}
}
addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.visibility = View.GONE
this@OperaViewerIcons.context.coroutineScope.launch(Dispatchers.Main) {
delay(250)
v.visibility = if (resolveCurrentMessageContext(mediaDownloader) != null) View.VISIBLE else View.GONE
}
}
override fun onViewDetachedFromWindow(v: View) {}
})
layoutParams = FrameLayout.LayoutParams(
(actionMenuIconSize * 1.5).toInt(),
(actionMenuIconSize * 1.5).toInt()
).apply {
setMargins(0, 0, 0, actionMenuIconMarginTop * 2 + this@OperaViewerIcons.context.userInterface.dpToPx(80))
marginEnd = actionMenuIconMarginTop * 2
marginStart = actionMenuIconMarginTop * 2
gravity = Gravity.BOTTOM or Gravity.END
}
})
return
}
parent.addView(createComposeView(parent.context) {
Icon(
imageVector = Icons.Default.RemoveRedEye,

View File

@@ -0,0 +1,33 @@
package me.eternal.purrfectsnap.core.util
const val SNAPCHAT_13_80_VERSION = "13.80.0.0"
private fun parseSnapchatVersion(versionName: String?): List<Int>? {
val normalizedVersion = versionName
?.substringBefore('-')
?.substringBefore(' ')
?.takeIf { it.isNotBlank() }
?: return null
return normalizedVersion.split('.')
.mapNotNull { segment ->
segment.filter(Char::isDigit).takeIf { it.isNotBlank() }?.toIntOrNull()
}
.takeIf { it.isNotEmpty() }
}
fun isSnapchatVersionAtLeast(versionName: String?, minimumVersion: String): Boolean {
val current = parseSnapchatVersion(versionName) ?: return false
val minimum = parseSnapchatVersion(minimumVersion) ?: return false
val maxLength = maxOf(current.size, minimum.size)
for (index in 0 until maxLength) {
val currentPart = current.getOrElse(index) { 0 }
val minimumPart = minimum.getOrElse(index) { 0 }
if (currentPart != minimumPart) {
return currentPart > minimumPart
}
}
return true
}

View File

@@ -7,8 +7,8 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.4.0
APP_VERSION_CODE=280
APP_VERSION_NAME=1.4.1
APP_VERSION_CODE=281
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c