3 Commits

Author SHA1 Message Date
ΞTΞRNAL
33664c1112 v1.6.0 2026-03-29 00:40:08 +05:30
ΞTΞRNAL
bb8e644b4c fix(PR): Select highest quality media by comparing resolution by Issac XT
Fix: Select highest quality media by comparing resolution
2026-03-28 23:53:04 +05:30
Issac
9e5b318120 Fix: Select highest quality media by comparing resolution instead of assuming array order 2026-03-28 11:01:39 +05:30
13 changed files with 737 additions and 15 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.5.9").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("308").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("310").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,9 @@
## v1.6.0
- New: Conversation Sound Effects!(Sound when you send or receive a msg while in chat)
- New: Call Metadata Notifier!
- New: Pin Messages!(Locally)
- Fix: Select highest quality media by comparing resolution(tq to Issac XT)
## v1.5.9
- New: Block Calls Feature!
- New: Unlock Zoom Limit Feature!

View File

@@ -1296,6 +1296,18 @@
"name": "Block Calls",
"description": "Blocks Snapchat call session updates so call UI and incoming call overlays do not appear"
},
"call_metadata_notifier": {
"name": "Call Metadata Notifier",
"description": "Shows a notification with captured call metadata after the call ends"
},
"conversation_sound_effects": {
"name": "Conversation Sound Effects",
"description": "Plays send and receive sounds inside an open conversation"
},
"conversation_sound_effects_style": {
"name": "Conversation Sound Style",
"description": "Choose the sound style used for in-conversation send and receive sounds"
},
"unlimited_conversation_pinning": {
"name": "Unlimited Conversation Pinning",
"description": "Allows you to pin an unlimited amount of conversations locally"
@@ -2516,6 +2528,12 @@
"back_custom_frame_rate": {
"null": "Device default FPS"
},
"conversation_sound_effects_style": {
"imessage": "iMessage",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"subtle": "Subtle"
},
"force_voice_note_format": {
"null": "Use Snapchat default"
},
@@ -2894,7 +2912,9 @@
"download_button": "Download",
"delete_logged_message_button": "Delete Logged Message",
"show_chat_edit_history": "Show Chat Edit History",
"convert_message": "Convert Message"
"convert_message": "Convert Message",
"pin_local_message": "Pin Message Locally",
"unpin_local_message": "Unpin Local Message"
},
"chat_wallpaper_downloader": {
"download_button": "Download Chat Wallpaper"
@@ -3134,6 +3154,16 @@
"dialog_title": "Start Call",
"dialog_message": "Are you sure you want to start a call?"
},
"call_metadata_notifier": {
"notification_channel_name": "Call Metadata",
"notification_title": "Call Metadata Captured",
"notification_empty": "No call metadata was captured"
},
"local_pinned_messages": {
"banner_title": "Pinned Message",
"pinned_toast": "Pinned message locally",
"unpinned_toast": "Removed local pinned message"
},
"half_swipe_notifier": {
"notification_channel_name": "Half Swipe",
"notification_content_dm": "{friend} just half-swiped into your chat for {duration} seconds",

View File

@@ -223,6 +223,13 @@ class MessagingTweaks : ConfigContainer() {
val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()}
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
val blockCalls = boolean("block_calls") { requireRestart() }
val callMetadataNotifier = boolean("call_metadata_notifier") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val conversationSoundEffects = boolean("conversation_sound_effects") { requireRestart() }
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "imessage", "telegram", "whatsapp", "subtle") {
requireRestart()
customOptionTranslationPath = "conversation_sound_effects_style"
addFlags(ConfigFlag.NO_TRANSLATE)
}.apply { set("imessage") }
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() }
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",

View File

@@ -114,7 +114,10 @@ class FeatureManager(
HideFriendFeedEntry(),
RequerySqlite(),
RefreshFriendSuggestions(),
LocalPinnedMessages(),
BlockCalls(),
CallMetadataNotifier(),
ConversationSoundEffects(),
CallButtonsOverride(),
SnapPreview(),
BypassScreenshotDetection(),

View File

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

View File

@@ -0,0 +1,126 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.media.AudioManager
import android.media.ToneGenerator
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.core.event.events.impl.ConversationUpdateEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.features.Feature
class ConversationSoundEffects : Feature("Conversation Sound Effects") {
private val seenIncomingMessageIds = LinkedHashSet<Long>()
private val maxTrackedMessages = 512
private data class ToneStep(
val tone: Int,
val durationMs: Int,
val pauseAfterMs: Long = 0L
)
private data class ToneSpec(
val sendPattern: List<ToneStep>,
val receivePattern: List<ToneStep>
)
private fun currentConversationId() = context.feature(Messaging::class).openedConversationUUID?.toString()
private fun styleSpec(): ToneSpec {
return when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"telegram" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_BEEP, 35),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 45, 25)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 70),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 35, 20)
)
)
"whatsapp" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 55)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 85),
ToneStep(ToneGenerator.TONE_PROP_ACK, 35, 15)
)
)
"subtle" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_PROMPT, 22)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 28)
)
)
else -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_PROMPT, 40),
ToneStep(ToneGenerator.TONE_PROP_BEEP, 28, 18)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 55),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 40, 22)
)
)
}
}
private fun playPattern(pattern: List<ToneStep>) {
if (context.isMainActivityPaused) return
context.executeAsync {
var toneGenerator: ToneGenerator? = null
runCatching {
toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 55)
pattern.forEach { step ->
toneGenerator?.startTone(step.tone, step.durationMs)
if (step.pauseAfterMs > 0) delay(step.pauseAfterMs)
}
}.also {
runCatching { toneGenerator?.release() }
}
}
}
private fun markSeen(messageId: Long): Boolean {
synchronized(seenIncomingMessageIds) {
val added = seenIncomingMessageIds.add(messageId)
while (seenIncomingMessageIds.size > maxTrackedMessages) {
seenIncomingMessageIds.remove(seenIncomingMessageIds.first())
}
return added
}
}
override fun init() {
if (!context.config.messaging.conversationSoundEffects.get()) return
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.destinations.conversations?.none { it.toString() == activeConversationId } != false) return@subscribe
event.addCallbackResult("onSuccess") {
val spec = styleSpec()
playPattern(spec.sendPattern)
}
}
context.event.subscribe(ConversationUpdateEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.conversationId != activeConversationId) return@subscribe
val myUserId = context.database.myUserId ?: return@subscribe
val spec = styleSpec()
event.messages
.asSequence()
.filter { it.senderId?.toString() != myUserId }
.mapNotNull { it.messageDescriptor?.messageId }
.filter { markSeen(it) }
.firstOrNull()
?.let {
playPattern(spec.receivePattern)
}
}
}
}

View File

@@ -121,6 +121,11 @@ class CameraTweaks : Feature("Camera Tweaks") {
param.setArg(1, captureResolutionConfig[1])
}
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
val key = param.arg<CaptureRequest.Key<*>>(0)
if (key == CaptureRequest.CONTROL_ZOOM_RATIO) return@hook
}
CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param ->
val key = param.argNullable<Key<*>>(0) ?: return@hook
@@ -146,14 +151,6 @@ class CameraTweaks : Feature("Camera Tweaks") {
}
}
}
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
val isFrontCamera = param.invokeOriginal(
arrayOf(CameraCharacteristics.LENS_FACING)
) == CameraCharacteristics.LENS_FACING_FRONT
val customFrameRate = (if (isFrontCamera) config.frontCustomFrameRate.getNullable() else config.backCustomFrameRate.getNullable())?.toIntOrNull() ?: return@hook
param.setResult(arrayOf(Range(customFrameRate, customFrameRate)))
}
}
if (config.blackPhotos.get()) {

View File

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

View File

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

View File

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

View File

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

View File

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