many fixes & improvements(v1.4.1)

This commit is contained in:
particle-box
2026-03-12 01:04:30 +05:30
parent 481e5840f0
commit 2135d66124
16 changed files with 412 additions and 78 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

@@ -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

@@ -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",
@@ -2766,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

@@ -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

@@ -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

@@ -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