2 Commits

Author SHA1 Message Date
ΞTΞRNAL
7a8ad81d48 v1.5.0 2026-03-21 16:40:54 +05:30
ΞTΞRNAL
7db60d6eb1 fix: skip when marking as seen for newer snap versions 2026-03-21 11:39:59 +05:30
8 changed files with 168 additions and 57 deletions

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.9").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("290").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.5.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("292").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,7 @@
## v1.5.0
- Fix: Skip when marking as seen for newer versions of Snapchat
- New: Hide Conversation Toolbox UI
## v1.4.9
- Fix: Force AMOLED Theme for newer versions of Snapchat
- New: Redesign some dialogs(Call confirmation & Mark Snaps as seen)

View File

@@ -2190,6 +2190,10 @@
"force_message_encryption": {
"name": "Force Message Encryption",
"description": "Prevents sending encrypted messages to people who don't have E2E Encryption enabled only when multiple conversations are selected"
},
"hide_conversation_toolbox_ui": {
"name": "Hide Conversation Toolbox UI",
"description": "Hides the PurrfectSnap conversation toolbox button that appears in Snapchat when End-To-End Encryption is enabled"
}
}
},

View File

@@ -51,6 +51,7 @@ class Experimental : ConfigContainer() {
class E2EEConfig : ConfigContainer(hasGlobalState = true) {
val encryptedMessageIndicator = boolean("encrypted_message_indicator")
val forceMessageEncryption = boolean("force_message_encryption")
val hideConversationToolboxUi = boolean("hide_conversation_toolbox_ui")
}
class AccountSwitcherConfig : ConfigContainer(hasGlobalState = true) {

View File

@@ -249,30 +249,34 @@ class EndToEndEncryption : MessagingRuleFeature(
}
onNextActivityCreate(defer = true) {
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
context.database.getDMOtherParticipant(it) != null
}) { dialog, conversationId ->
val friendId = remember {
context.database.getDMOtherParticipant(conversationId)
} ?: return@addComposable
val fingerprint = remember {
runCatching {
e2eeInterface.getSecretFingerprint(friendId)
}.getOrNull()
}
if (fingerprint != null) {
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
} else {
Text(translation["toolbox.no_shared_key"])
}
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
dialog.dismiss()
warnKeyOverwrite(friendId) {
askForKeys(conversationId)
val hideConversationToolboxUi by context.config.experimental.e2eEncryption.hideConversationToolboxUi
if (!hideConversationToolboxUi) {
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
context.database.getDMOtherParticipant(it) != null
}) { dialog, conversationId ->
val friendId = remember {
context.database.getDMOtherParticipant(conversationId)
} ?: return@addComposable
val fingerprint = remember {
runCatching {
e2eeInterface.getSecretFingerprint(friendId)
}.getOrNull()
}
if (fingerprint != null) {
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
} else {
Text(translation["toolbox.no_shared_key"])
}
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
dialog.dismiss()
warnKeyOverwrite(friendId) {
askForKeys(conversationId)
}
}) {
Text(translation["toolbox.initiate_exchange_button"])
}
}) {
Text(translation["toolbox.initiate_exchange_button"])
}
}

View File

@@ -47,16 +47,31 @@ fun View.addForegroundDrawable(tag: String, drawable: Drawable) {
updateForegroundDrawable()
}
fun View.triggerCloseTouchEvent() {
arrayOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP).forEach {
this.dispatchTouchEvent(
MotionEvent.obtain(
SystemClock.uptimeMillis(),
SystemClock.uptimeMillis(),
it, 0f, 0f, 0
)
)
}
fun View.dispatchSyntheticTap(x: Float, y: Float, tapDurationMs: Long = 50L) {
val downTime = SystemClock.uptimeMillis()
val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
dispatchTouchEvent(downEvent)
downEvent.recycle()
val upEvent = MotionEvent.obtain(downTime, downTime + tapDurationMs, MotionEvent.ACTION_UP, x, y, 0)
dispatchTouchEvent(upEvent)
upEvent.recycle()
}
fun View.triggerCloseTouchEvent(x: Float = 0f, y: Float = 0f, tapDurationMs: Long = 50L) {
dispatchSyntheticTap(x, y, tapDurationMs)
}
fun View.triggerCloseTouchEventAtFraction(
xFraction: Float,
yFraction: Float = 0.5f,
tapDurationMs: Long = 50L
) {
val targetWidth = width.takeIf { it > 0 } ?: measuredWidth
val targetHeight = height.takeIf { it > 0 } ?: measuredHeight
val x = if (targetWidth > 0) targetWidth * xFraction.coerceIn(0f, 1f) else 0f
val y = if (targetHeight > 0) targetHeight * yFraction.coerceIn(0f, 1f) else 0f
triggerCloseTouchEvent(x, y, tapDurationMs)
}
fun Activity.triggerRootCloseTouchEvent() {

View File

@@ -45,6 +45,7 @@ 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.ui.triggerCloseTouchEventAtFraction
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
@@ -259,23 +260,113 @@ class OperaViewerIcons : AbstractMenu() {
visibleRect.width() > 0
}
private fun hasVisibleOpenLayout(view: View): Boolean {
private fun findVisibleOpenLayout(view: View): View? {
if (view.javaClass.hasNameSuffixInHierarchy("OpenLayout") && isActuallyVisible(view)) {
return true
return view
}
val viewGroup = view as? ViewGroup ?: return false
val viewGroup = view as? ViewGroup ?: return null
for (index in 0 until viewGroup.childCount) {
if (hasVisibleOpenLayout(viewGroup.getChildAt(index))) {
return true
}
findVisibleOpenLayout(viewGroup.getChildAt(index))?.let { return it }
}
return false
return null
}
private fun findVisibleModernViewerContainer(): View? {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content) ?: return null
return findVisibleOpenLayout(contentView)
}
private fun hasVisibleModernViewerContainer(): Boolean {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content) ?: return false
return hasVisibleOpenLayout(contentView)
return findVisibleModernViewerContainer() != null
}
private fun currentViewerMessageContext(mediaDownloader: MediaDownloader): OperaViewerMessageContext? {
return mediaDownloader.resolveViewerMessageContextFromParamMap()?.also {
viewerMessageContextState.value = it
} ?: viewerMessageContextState.value
}
private fun hasViewerAdvanced(
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext
): Boolean {
if (!hasVisibleModernViewerContainer()) {
return true
}
return currentViewerMessageContext(mediaDownloader)?.let { it != originalMessageContext } == true
}
private suspend fun waitForViewerAdvance(
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext,
timeoutMs: Long
): Boolean {
var elapsedMs = 0L
while (elapsedMs < timeoutMs) {
delay(40)
elapsedMs += 40
if (hasViewerAdvanced(mediaDownloader, originalMessageContext)) {
return true
}
}
return hasViewerAdvanced(mediaDownloader, originalMessageContext)
}
private fun dispatchLegacySkipGesture(parent: ViewGroup?) {
if (parent != null) {
var touchedParent = false
parent.iterateParent {
touchedParent = true
it.triggerCloseTouchEvent()
false
}
if (touchedParent) return
}
context.mainActivity
?.findViewById<View>(android.R.id.content)
?.triggerCloseTouchEvent()
}
private fun dispatchForwardHotZoneTap(target: View?, xFraction: Float) {
target?.triggerCloseTouchEventAtFraction(xFraction = xFraction, yFraction = 0.5f)
}
private suspend fun skipMarkedSnap(
parent: ViewGroup?,
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext
) {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content)
val skipAttempts = listOf<suspend () -> Unit>(
{
dispatchLegacySkipGesture(parent)
},
{
dispatchForwardHotZoneTap(findVisibleModernViewerContainer() ?: contentView, 0.88f)
},
{
dispatchForwardHotZoneTap(contentView ?: findVisibleModernViewerContainer(), 0.88f)
},
{
val target = findVisibleModernViewerContainer() ?: contentView
dispatchForwardHotZoneTap(target, 0.88f)
delay(55)
if (!hasViewerAdvanced(mediaDownloader, originalMessageContext)) {
dispatchForwardHotZoneTap(target, 0.94f)
}
}
)
for ((index, attempt) in skipAttempts.withIndex()) {
attempt()
if (waitForViewerAdvance(mediaDownloader, originalMessageContext, if (index == 0) 120L else 180L)) {
return
}
}
}
private fun Class<*>?.hasNameSuffixInHierarchy(suffix: String): Boolean {
@@ -326,7 +417,8 @@ class OperaViewerIcons : AbstractMenu() {
}
private suspend fun markCurrentSnapAsSeen(parent: ViewGroup?) {
val messageContext = resolveCurrentMessageContext(context.feature(MediaDownloader::class)) ?: return
val mediaDownloader = context.feature(MediaDownloader::class)
val messageContext = resolveCurrentMessageContext(mediaDownloader) ?: return
val result = context.feature(AutoMarkAsRead::class).markSnapAsSeen(
messageContext.conversationId,
messageContext.clientMessageId
@@ -335,16 +427,7 @@ class OperaViewerIcons : AbstractMenu() {
if (result == "DUPLICATEREQUEST" || result == null) {
if (context.config.messaging.skipWhenMarkingAsSeen.get()) {
withContext(Dispatchers.Main) {
if (parent != null) {
parent.iterateParent {
it.triggerCloseTouchEvent()
false
}
} else {
context.mainActivity
?.findViewById<View>(android.R.id.content)
?.triggerCloseTouchEvent()
}
skipMarkedSnap(parent, mediaDownloader, messageContext)
}
}
}

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.9
APP_VERSION_CODE=290
APP_VERSION_NAME=1.5.0
APP_VERSION_CODE=292
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c