5 Commits

Author SHA1 Message Date
ΞTΞRNAL
6491288513 v1.6.1 2026-03-29 12:35:34 +05:30
Ξ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
ΞTΞRNAL
21fba253b1 v1.5.9 2026-03-28 12:57:15 +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
18 changed files with 1119 additions and 241 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.8").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("306").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.1").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("312").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,18 @@
## v1.6.1
- Fix: Custom Frame Rate
- Fix: Conversation Sound Style
- Fix: Auto Reactions when downloading stories(Removed story thumbnail feature)
## v1.6.0
- New: Conversation Sound Effects!(Sound when you send or receive a msg while in chat)
- New: Call Metadata Notifier!
- 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!
## v1.5.8
- New: Implemented 30Mbps Video and 320kbps/48kHz Audio bitrates(tq to Kaladin)
- Fix: Advanced hardware ISP processing modes for superior dynamic range and less noisy video footage(tq to Kaladin)

View File

@@ -1292,6 +1292,18 @@
"name": "Call Start Confirmation",
"description": "Shows a confirmation dialog when starting a call"
},
"block_calls": {
"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_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"
@@ -2010,6 +2022,14 @@
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": {
"name": "Video Recording Timer",
"description": "Shows a recording timer overlay when recording video"
},
"unlock_zoom_limit": {
"name": "Unlock Zoom Limit",
"description": "Overrides the max camera zoom Snapchat reads from the device"
},
"max_zoom_override": {
"name": "Max Zoom Override",
"description": "Maximum zoom ratio to report to Snapchat, for example 120"
}
}
},
@@ -2504,6 +2524,13 @@
"back_custom_frame_rate": {
"null": "Device default FPS"
},
"conversation_sound_effects_style": {
"disabled": "Disabled",
"imessage": "iMessage",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"subtle": "Subtle"
},
"force_voice_note_format": {
"null": "Use Snapchat default"
},
@@ -2882,7 +2909,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"
@@ -3122,6 +3151,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

@@ -58,6 +58,12 @@ class Camera : ConfigContainer() {
val overrideFrontResolution get() = _overrideFrontResolution
val overrideBackResolution get() = _overrideBackResolution
val videoRecordTimer = boolean("video_record_timer")
val unlockZoomLimit = boolean("unlock_zoom_limit") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val maxZoomOverride = float("max_zoom_override", 120f) {
requireRestart()
addFlags(ConfigFlag.NO_TRANSLATE)
inputCheck = { (it.toFloatOrNull() ?: 0f) in 1f..500f }
}
val audioVideoOptimizations = boolean("audio_video", defaultValue = true) { requireRestart() }
val cameraOptimizations = boolean("camera_tweaks", defaultValue = false) { addNotices(FeatureNotice.UNSTABLE); requireRestart() }

View File

@@ -222,6 +222,14 @@ class MessagingTweaks : ConfigContainer() {
val disableReplayInFF = boolean("disable_replay_in_ff")
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 conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "disabled", "imessage", "telegram", "whatsapp", "subtle") {
requireRestart()
customOptionTranslationPath = "conversation_sound_effects_style"
addFlags(ConfigFlag.NO_TRANSLATE)
addFlags(ConfigFlag.NO_DISABLE_KEY)
}.apply { set("disabled") }
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() }
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",

View File

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

View File

@@ -12,7 +12,6 @@ import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
@@ -30,14 +29,11 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -45,8 +41,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -125,11 +119,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
var lastSeenMapParams: ParamMap? = null
private set
private val storyPreviewCache = mutableMapOf<String, MutableMap<Int, Bitmap>>()
@Volatile
private var pendingBatchDownloadIndices: MutableList<Int>? = null
@Volatile
private var batchForceAllowDuplicate: Boolean = false
private val translations by lazy {
context.translation.getCategory("download_processor")
}
@@ -267,68 +256,10 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val tr = context.translation.getCategory("download_processor.story_snap_dialog")
val cancelStr = context.translation["button.cancel"]
val downloadStr = context.translation["button.download"]
val previewCacheKey = buildString {
append(paramMap["STORY_ID"]?.toString() ?: "story")
append("|")
append(paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: "user")
append("|")
append(totalCount)
}
context.runOnUiThread {
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
val selected = remember { mutableStateListOf<Int>().apply { add(currentIndex) } }
val previewBitmaps = remember { mutableStateMapOf<Int, Bitmap?>() }
val previewLoading = remember { mutableStateMapOf<Int, Boolean>() }
LaunchedEffect(Unit) {
if (!selected.contains(currentIndex)) selected.add(currentIndex)
mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri?.let { currentUri ->
previewLoading[currentIndex] = true
previewBitmaps[currentIndex] = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(currentUri) }
previewLoading[currentIndex] = false
}
synchronized(storyPreviewCache) {
storyPreviewCache[previewCacheKey]?.forEach { (index, bitmap) ->
previewBitmaps[index] = bitmap
}
}
}
LaunchedEffect(previewCacheKey) {
val overlay = context.feature(OperaStoryOverlay::class)
val cachedIndices = synchronized(storyPreviewCache) {
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }.keys.toSet()
}
val indicesToScan = (0 until totalCount).filter { it != currentIndex && it !in cachedIndices }
try {
for (targetIndex in indicesToScan) {
val jumped = withContext(Dispatchers.Main) {
overlay.requestJumpToSnap(targetIndex, totalCount)
}
if (!jumped) continue
val reached = waitForStoryIndex(targetIndex)
if (!reached) continue
val uri = lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri ?: continue
previewLoading[targetIndex] = true
val bitmap = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(uri) }
previewLoading[targetIndex] = false
if (bitmap != null) {
previewBitmaps[targetIndex] = bitmap
synchronized(storyPreviewCache) {
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }[targetIndex] = bitmap
}
}
}
} finally {
withContext(Dispatchers.Main) {
overlay.requestJumpToSnap(currentIndex, totalCount)
}
}
}
PurrfectGlassCard(
title = tr["title"],
@@ -362,35 +293,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
},
colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)
)
Box(
modifier = Modifier
.size(54.dp)
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
val rowBitmap = previewBitmaps[index]
val rowLoading = previewLoading[index] == true
when {
rowBitmap != null -> Image(
bitmap = rowBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.size(54.dp)
.background(Color.Transparent, RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
)
rowLoading -> CircularProgressIndicator(
color = PurrfectOverlayPalette.glowPrimary,
modifier = Modifier.size(22.dp),
strokeWidth = 2.dp
)
else -> Icon(
imageVector = Icons.Outlined.Image,
contentDescription = null,
tint = PurrfectOverlayPalette.textSecondary
)
}
}
Text(
label,
style = MaterialTheme.typography.bodyMedium,
@@ -435,10 +337,15 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
Button(
onClick = {
if (selected.isNotEmpty()) {
startBatchDownload(selected.sorted().toMutableList(), allowDuplicate)
alertDialog.dismiss()
if (!selected.contains(currentIndex)) return@Button
context.executeAsync {
runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) }
.onFailure {
context.log.error("Story download failed", it)
context.shortToast(translations["failed_generic_toast"])
}
}
alertDialog.dismiss()
},
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(14.dp),
@@ -457,124 +364,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private suspend fun waitForStoryIndex(targetIndex: Int, timeoutMs: Long = 3000L): Boolean {
val startedAt = System.currentTimeMillis()
while (System.currentTimeMillis() - startedAt < timeoutMs) {
if (lastSeenMapParams?.getStorySnapIndex() == targetIndex) return true
kotlinx.coroutines.delay(60L)
}
return false
}
private fun loadStoryPreviewBitmap(uriString: String): Bitmap? {
return runCatching {
val uri = Uri.parse(uriString)
when (uri.scheme?.lowercase()) {
"content" -> context.androidContext.contentResolver.openInputStream(uri)?.use(BitmapFactory::decodeStream)
"file", null -> BitmapFactory.decodeFile(uri.path)
"http", "https" -> {
runCatching {
OkHttpClient().newCall(Request.Builder().url(uriString).build()).execute().use { response ->
response.body?.byteStream()?.use { stream -> BitmapFactory.decodeStream(stream) }
}
}.getOrNull() ?: run {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(uriString, emptyMap())
retriever.frameAtTime
} finally {
runCatching { retriever.release() }
}
}
}
else -> null
} ?: run {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(context.androidContext, uri)
retriever.frameAtTime
} finally {
runCatching { retriever.release() }
}
}
}.getOrNull()
}
private fun startBatchDownload(indices: MutableList<Int>, allowDuplicate: Boolean) {
if (indices.isEmpty()) return
val paramMap = lastSeenMapParams ?: return
val mediaInfoMap = lastSeenMediaInfoMap ?: return
pendingBatchDownloadIndices = indices
batchForceAllowDuplicate = allowDuplicate
val currentIndex = paramMap.getStorySnapIndex() ?: 0
val targetIndex = indices.first()
val totalCount = paramMap.getStorySnapTotal()
if (currentIndex == targetIndex) {
processNextBatchDownload(paramMap, mediaInfoMap)
} else {
val jumped = context.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount)
if (!jumped) {
pendingBatchDownloadIndices = null
context.shortToast(translations["batch_download_jump_failed_toast"])
}
}
}
private fun downloadSingleSnap(paramMap: ParamMap, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
context.executeAsync {
runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, batchForceAllowDuplicate) }
.onFailure {
context.log.error("Batch download failed", it)
context.shortToast(translations["failed_generic_toast"])
}
}
}
private fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map<SplitMediaAssetType, MediaInfo>) {
val queue = pendingBatchDownloadIndices ?: return
if (queue.isEmpty()) {
flushPendingMergeAndComplete()
return
}
val currentIndex = paramMap.getStorySnapIndex() ?: -1
if (currentIndex != queue.first()) return
queue.removeAt(0)
downloadSingleSnap(paramMap, mediaInfoMap)
if (queue.isEmpty()) {
flushPendingMergeAndComplete()
} else {
val totalCount = paramMap.getStorySnapTotal()
context.runOnUiThread {
fun tryJump(retryCount: Int = 0) {
val delayMs = if (retryCount == 0) 120L else 220L
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
val jumped = runCatching {
context.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount)
}.getOrNull() == true
if (!jumped && retryCount < 1) {
tryJump(retryCount + 1)
} else if (!jumped) {
pendingBatchDownloadIndices = null
context.shortToast(translations["batch_download_jump_failed_toast"])
}
}, delayMs)
}
tryJump()
}
}
}
private fun flushPendingMergeAndComplete() {
pendingBatchDownloadIndices = null
context.shortToast(translations["batch_download_complete_toast"])
}
fun showLastOperaDebugMediaInfo() {
if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return
@@ -1063,15 +852,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
lastSeenMapParams = mediaParamMap
lastSeenMediaInfoMap = mediaInfoMap
if (pendingBatchDownloadIndices != null) {
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
if (pendingBatchDownloadIndices != null) {
processNextBatchDownload(mediaParamMap, mediaInfoMap)
}
}, 80L)
return@onOperaViewStateCallback
}
if (!shouldAutoDownload) {
return@onOperaViewStateCallback
}

View File

@@ -7,10 +7,10 @@ import me.eternal.purrfectsnap.core.util.hook.hook
class DisableTelecomFramework: Feature("Disable Telecom Framework") {
override fun init() {
if (!context.config.global.disableTelecomFramework.get()) return
if (!context.config.global.disableTelecomFramework.get() && !context.config.messaging.blockCalls.get()) return
ContextWrapper::class.java.hook("getSystemService", HookStage.BEFORE) { param ->
if (param.arg<Any>(0).toString() == "telecom") param.setResult(null)
}
}
}
}

View File

@@ -0,0 +1,171 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.Notification
import android.app.NotificationManager
import android.content.Intent
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.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import java.nio.ByteBuffer
class BlockCalls : Feature("Block Calls") {
private fun isBlockedCallNotificationType(type: String?): Boolean {
return type?.lowercase() in setOf(
"initiate_audio",
"initiate_video",
"abandon_audio",
"abandon_video"
)
}
private fun shouldBlockVolatilePayload(eventData: ProtoReader): Boolean {
val dump = eventData.toString().lowercase()
return listOf(
"\"calluuid\"",
"\"callaction\"",
"\"messagetype\":\"caller_push\"",
"\"messagetype\":\"streamer_data_v2\"",
"\"messagetype\":\"callee_push\"",
"\"messagetype\":\"caller_hangup\"",
"\"messagetype\":\"call_end\""
).any { it in dump }
}
override fun init() {
if (!context.config.messaging.blockCalls.get()) return
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
if (!isBlockedCallNotificationType(intent.getStringExtra("type"))) return@hook
context.log.verbose("Blocked Firebase call message ${intent.getStringExtra("type")}", "BlockCalls")
param.setResult(null)
}
}
runCatching {
NotificationManager::class.java.findRestrictedMethod { it.name == "notifyAsUser" }?.hook(HookStage.BEFORE) { param ->
val notification = param.argNullable<Notification>(2) ?: return@hook
val notificationType = notification.extras
?.getBundle("system_notification_extras")
?.getString("notification_type")
if (!isBlockedCallNotificationType(notificationType)) return@hook
context.log.verbose("Blocked call notification $notificationType", "BlockCalls")
param.setResult(null)
}
}
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)
val eventType = reader.getString(1, 1) ?: return@hook
if (eventType != "volatile") return@hook
val eventData = reader.followPath(1, 2) ?: return@hook
if (!shouldBlockVolatilePayload(eventData)) return@hook
context.log.verbose("Blocked volatile call payload", "BlockCalls")
param.setResult(null)
}
}
val talkCoreNames = listOf(
"com.snapchat.talkcorev3.TalkCore\$CppProxy",
"com.snapchat.talkcorev4.TalkCore\$CppProxy",
"com.snapchat.talkcore.TalkCore\$CppProxy"
)
talkCoreNames.forEach { className ->
runCatching {
findClass(className).apply {
hook("updateTSCallingSession", HookStage.BEFORE) { param ->
val params = param.argNullable<Any>(0)
val conversationId = params?.getObjectFieldOrNull("mConversationId")?.toString()
val inCall = params?.getObjectFieldOrNull("mInCall") as? Boolean
context.log.verbose(
"Blocked talk session update inCall=$inCall convo=$conversationId",
"BlockCalls"
)
param.setResult(null)
}
hook("disposeTSCallingSession", HookStage.BEFORE) { param ->
context.log.verbose("Blocked talk session dispose", "BlockCalls")
param.setResult(null)
}
}
}
}
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) return@hookConstructor
instance.setObjectField("mInCall", false)
context.log.verbose("Forced TSCallingStateUpdateParams.mInCall=false", "BlockCalls")
}
}
}
context.event.subscribe(AddViewEvent::class) { event ->
val viewName = event.viewClassName.lowercase()
val parentName = event.parent.javaClass.name.lowercase()
val exactCallUi = setOf(
"com.snap.talk.callviewwrapper",
"com.snap.talk.core.callcontainer"
)
val callUiParents = setOf(
"com.snap.talk.core.callcontainer"
)
if (viewName in exactCallUi || parentName in callUiParents) {
context.log.verbose(
"Suppressed view ${event.viewClassName} parent=${event.parent.javaClass.name}",
"BlockCalls"
)
event.view.hideViewCompletely()
return@subscribe
}
if (viewName.endsWith("callbuttonsview") ||
(viewName.contains("call") && (
viewName.contains("overlay") ||
viewName.contains("incoming") ||
viewName.contains("ringing") ||
viewName.contains("ringer")
))
) {
event.view.hideViewCompletely()
}
}
}
}

View File

@@ -80,12 +80,13 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
override fun init() {
val hideUiComponents by context.config.userInterface.hideUiComponents
val blockCalls = context.config.messaging.blockCalls.get()
val hideProfileCallButtons = hideUiComponents.contains("hide_profile_call_buttons")
val hideChatCallButtons = hideUiComponents.contains("hide_chat_call_buttons")
val hideProfileCallButtons = blockCalls || hideUiComponents.contains("hide_profile_call_buttons")
val hideChatCallButtons = blockCalls || hideUiComponents.contains("hide_chat_call_buttons")
val callStartConfirmation = context.config.messaging.callStartConfirmation.get()
if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation) return
if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation && !blockCalls) return
var actionSheetVideoCallButtonId = -1
var actionSheetAudioCallButtonId = -1
@@ -111,7 +112,7 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
}
onNextActivityCreate {
if (callStartConfirmation) {
if (callStartConfirmation || blockCalls) {
(runCatching { findClass("com.snap.valdi.views.ValdiRootView") }.getOrNull()
?: findClass("com.snap.composer.views.ComposerRootView"))
.hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
@@ -122,6 +123,10 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
if (childComposerView.children().count {
it::class.java == childComposerView::class.java
} != 2) return@hook
if (blockCalls) {
param.setResult(true)
return@hook
}
hookTouchEvent(param, param.arg(0)) {
param.invokeOriginal()
}
@@ -131,6 +136,10 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
val view = param.thisObject<View>().takeIf { it.id != -1 } ?: return@hook
if (view.id != actionSheetAudioCallButtonId && view.id != actionSheetVideoCallButtonId) return@hook
if (blockCalls) {
param.setResult(true)
return@hook
}
hookTouchEvent(param, param.arg(0)) {
arrayOf(
MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0),

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,281 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.core.event.events.impl.ConversationUpdateEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.features.Feature
import kotlin.math.PI
import kotlin.math.exp
import kotlin.math.sin
class ConversationSoundEffects : Feature("Conversation Sound Effects") {
private val seenIncomingMessageIds = LinkedHashSet<Long>()
private val maxTrackedMessages = 512
private data class BubbleSpec(
val durationMs: Int,
val startFreqHz: Double,
val endFreqHz: Double,
val overtoneFreqHz: Double,
val amplitude: Double
)
private data class BubbleStep(
val spec: BubbleSpec,
val pauseAfterMs: Long = 0L
)
private val iMessageSendBubble = BubbleSpec(
durationMs = 78,
startFreqHz = 1160.0,
endFreqHz = 690.0,
overtoneFreqHz = 1820.0,
amplitude = 0.50
)
private val iMessageReceiveBubble = BubbleSpec(
durationMs = 92,
startFreqHz = 1040.0,
endFreqHz = 640.0,
overtoneFreqHz = 1680.0,
amplitude = 0.46
)
private val whatsappSendBubble = BubbleSpec(
durationMs = 86,
startFreqHz = 860.0,
endFreqHz = 520.0,
overtoneFreqHz = 1410.0,
amplitude = 0.52
)
private val whatsappReceiveBubble = BubbleSpec(
durationMs = 94,
startFreqHz = 920.0,
endFreqHz = 560.0,
overtoneFreqHz = 1520.0,
amplitude = 0.50
)
private val telegramSendPrimary = BubbleSpec(
durationMs = 58,
startFreqHz = 1110.0,
endFreqHz = 820.0,
overtoneFreqHz = 1710.0,
amplitude = 0.42
)
private val telegramSendAccent = BubbleSpec(
durationMs = 34,
startFreqHz = 1360.0,
endFreqHz = 980.0,
overtoneFreqHz = 2060.0,
amplitude = 0.22
)
private val telegramReceivePrimary = BubbleSpec(
durationMs = 72,
startFreqHz = 1080.0,
endFreqHz = 780.0,
overtoneFreqHz = 1680.0,
amplitude = 0.44
)
private val telegramReceiveAccent = BubbleSpec(
durationMs = 42,
startFreqHz = 1280.0,
endFreqHz = 940.0,
overtoneFreqHz = 1940.0,
amplitude = 0.18
)
private val subtleSendBubble = BubbleSpec(
durationMs = 60,
startFreqHz = 760.0,
endFreqHz = 520.0,
overtoneFreqHz = 1180.0,
amplitude = 0.26
)
private val subtleReceiveBubble = BubbleSpec(
durationMs = 66,
startFreqHz = 800.0,
endFreqHz = 560.0,
overtoneFreqHz = 1260.0,
amplitude = 0.24
)
private fun currentConversationId() = context.feature(Messaging::class).openedConversationUUID?.toString()
private fun buildBubblePcm(spec: BubbleSpec, sampleRate: Int = 44_100): ByteArray {
val sampleCount = (sampleRate * (spec.durationMs / 1000.0)).toInt().coerceAtLeast(1)
val pcm = ByteArray(sampleCount * 2)
for (i in 0 until sampleCount) {
val progress = i.toDouble() / sampleCount.toDouble()
val envelope = exp(-4.8 * progress) * (1.0 - exp(-20.0 * progress))
val freq = spec.startFreqHz + (spec.endFreqHz - spec.startFreqHz) * progress
val t = i.toDouble() / sampleRate.toDouble()
val fundamental = sin(2.0 * PI * freq * t)
val overtone = 0.18 * sin(2.0 * PI * spec.overtoneFreqHz * t)
val airyTail = 0.08 * sin(2.0 * PI * (freq * 0.48) * t)
val warmth = 0.14 * sin(2.0 * PI * (freq * 0.24) * t)
val sample = ((fundamental + overtone + airyTail + warmth) * envelope * spec.amplitude)
.coerceIn(-1.0, 1.0)
val shortValue = (sample * Short.MAX_VALUE).toInt().toShort()
pcm[i * 2] = (shortValue.toInt() and 0xFF).toByte()
pcm[i * 2 + 1] = ((shortValue.toInt() shr 8) and 0xFF).toByte()
}
return pcm
}
private fun playBubble(spec: BubbleSpec) {
if (context.isMainActivityPaused) return
context.executeAsync {
val sampleRate = 44_100
val pcm = buildBubblePcm(spec, sampleRate)
val audioTrack = AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
pcm.size,
AudioTrack.MODE_STATIC,
AudioManager.AUDIO_SESSION_ID_GENERATE
)
runCatching {
audioTrack.write(pcm, 0, pcm.size)
audioTrack.play()
delay(spec.durationMs.toLong() + 24L)
}.also {
runCatching {
audioTrack.stop()
audioTrack.release()
}
}
}
}
private fun playBubbleSequence(steps: List<BubbleStep>) {
if (context.isMainActivityPaused) return
context.executeAsync {
steps.forEach { step ->
val sampleRate = 44_100
val pcm = buildBubblePcm(step.spec, sampleRate)
val audioTrack = AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_EVENT)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
pcm.size,
AudioTrack.MODE_STATIC,
AudioManager.AUDIO_SESSION_ID_GENERATE
)
runCatching {
audioTrack.write(pcm, 0, pcm.size)
audioTrack.play()
delay(step.spec.durationMs.toLong() + step.pauseAfterMs + 18L)
}.also {
runCatching {
audioTrack.stop()
audioTrack.release()
}
}
}
}
}
private fun playStyledSend() {
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"imessage" -> playBubble(iMessageSendBubble)
"whatsapp" -> playBubble(whatsappSendBubble)
"telegram" -> playBubbleSequence(
listOf(
BubbleStep(telegramSendPrimary, pauseAfterMs = 16L),
BubbleStep(telegramSendAccent)
)
)
else -> playBubble(subtleSendBubble)
}
}
private fun playStyledReceive() {
when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"imessage" -> playBubble(iMessageReceiveBubble)
"whatsapp" -> playBubbleSequence(
listOf(
BubbleStep(whatsappReceiveBubble, pauseAfterMs = 12L),
BubbleStep(
whatsappReceiveBubble.copy(
durationMs = 42,
startFreqHz = 1210.0,
endFreqHz = 860.0,
overtoneFreqHz = 1980.0,
amplitude = 0.20
)
)
)
)
"telegram" -> playBubbleSequence(
listOf(
BubbleStep(telegramReceivePrimary, pauseAfterMs = 14L),
BubbleStep(telegramReceiveAccent)
)
)
else -> playBubble(subtleReceiveBubble)
}
}
private fun markSeen(messageId: Long): Boolean {
synchronized(seenIncomingMessageIds) {
val added = seenIncomingMessageIds.add(messageId)
while (seenIncomingMessageIds.size > maxTrackedMessages) {
seenIncomingMessageIds.remove(seenIncomingMessageIds.first())
}
return added
}
}
override fun init() {
if (context.config.messaging.conversationSoundEffectsStyle.get() == "disabled") return
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.destinations.conversations?.none { it.toString() == activeConversationId } != false) return@subscribe
event.addCallbackResult("onSuccess") {
playStyledSend()
}
}
context.event.subscribe(ConversationUpdateEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.conversationId != activeConversationId) return@subscribe
val myUserId = context.database.myUserId ?: return@subscribe
event.messages
.asSequence()
.filter { it.senderId?.toString() != myUserId }
.mapNotNull { it.messageDescriptor?.messageId }
.filter { markSeen(it) }
.firstOrNull()
?.let {
playStyledReceive()
}
}
}
}

View File

@@ -135,6 +135,18 @@ class CameraTweaks : Feature("Camera Tweaks") {
}
}
if (config.unlockZoomLimit.get()) {
val maxZoom = config.maxZoomOverride.get().coerceAtLeast(1f)
when {
key == CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM -> {
param.setResult(maxZoom)
}
key.name == "android.control.zoomRatioRange" -> {
param.setResult(Range(1f, maxZoom))
}
}
}
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
val isFrontCamera = param.invokeOriginal(
arrayOf(CameraCharacteristics.LENS_FACING)

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.8
APP_VERSION_CODE=306
APP_VERSION_NAME=1.6.1
APP_VERSION_CODE=312
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c