Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33664c1112 | ||
|
|
bb8e644b4c | ||
|
|
9e5b318120 |
@@ -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.
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -114,7 +114,10 @@ class FeatureManager(
|
||||
HideFriendFeedEntry(),
|
||||
RequerySqlite(),
|
||||
RefreshFriendSuggestions(),
|
||||
LocalPinnedMessages(),
|
||||
BlockCalls(),
|
||||
CallMetadataNotifier(),
|
||||
ConversationSoundEffects(),
|
||||
CallButtonsOverride(),
|
||||
SnapPreview(),
|
||||
BypassScreenshotDetection(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user