This commit is contained in:
RSR/
2026-02-07 19:20:39 +04:00
parent ff398ccc9a
commit e3008e0c9c
4 changed files with 116 additions and 73 deletions

View File

@@ -3,11 +3,14 @@ package me.eternal.purrfectsnap.core.action.impl
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Color as AndroidColor
import android.graphics.drawable.GradientDrawable
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import android.content.res.ColorStateList
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
@@ -145,8 +148,11 @@ class BulkMessagingAction : AbstractAction() {
action: suspend (id: String, setDialogMessage: (String) -> Unit) -> Unit = { _, _ -> } action: suspend (id: String, setDialogMessage: (String) -> Unit) -> Unit = { _, _ -> }
) = context.coroutineScope.launch { ) = context.coroutineScope.launch {
val statusTextView = TextView(ctx) val statusTextView = TextView(ctx)
val progressBar = ProgressBar(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
}
val dialog = withContext(Dispatchers.Main) { val dialog = withContext(Dispatchers.Main) {
ViewAppearanceHelper.newAlertDialogBuilder(ctx) val d = ViewAppearanceHelper.newAlertDialogBuilder(ctx)
.setTitle("...") .setTitle("...")
.setView(LinearLayout(ctx).apply { .setView(LinearLayout(ctx).apply {
val padding = (16 * ctx.resources.displayMetrics.density).toInt() val padding = (16 * ctx.resources.displayMetrics.density).toInt()
@@ -160,12 +166,28 @@ class BulkMessagingAction : AbstractAction() {
setSingleLine(false) setSingleLine(false)
setPadding(0, 0, 0, spacing) setPadding(0, 0, 0, spacing)
}) })
addView(ProgressBar(ctx).apply { addView(progressBar)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
})
}) })
.setCancelable(false) .setCancelable(false)
.show() .show()
// Style dialog to match app UI (gradient, app colors)
val density = ctx.resources.displayMetrics.density
d.window?.setBackgroundDrawable(GradientDrawable(
GradientDrawable.Orientation.TOP_BOTTOM,
intArrayOf(
AndroidColor.parseColor("#2A2452"),
AndroidColor.parseColor("#1A143A")
)
).apply {
cornerRadius = (20 * density).toFloat()
})
val titleId = ctx.resources.getIdentifier("alertTitle", "id", "android")
if (titleId != 0) {
(d.window?.decorView?.findViewById<View>(titleId) as? TextView)?.setTextColor(AndroidColor.WHITE)
}
statusTextView.setTextColor(AndroidColor.parseColor("#E0E0E0"))
progressBar.indeterminateTintList = ColorStateList.valueOf(AndroidColor.parseColor("#8C7BFF"))
d
} }
ids.forEachIndexed { index, id -> ids.forEachIndexed { index, id ->
@@ -350,12 +372,19 @@ class BulkMessagingAction : AbstractAction() {
val myLocation = betterLocation.locationHistory[context.database.myUserId] val myLocation = betterLocation.locationHistory[context.database.myUserId]
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val friendIdsStillInFeed = runCatching { // Sync with Snapchat feed: only show friends whose DM conversation still exists in feed
context.database.getFeedEntries(Int.MAX_VALUE) // (FriendsFeedView excludes cleared via "clearedTimestamp < lastInteractionTimestamp")
.filter { it.conversationType == 0 && it.participantsSize == 2 } // BUT: only apply this filtering to certain filters that specifically need it
.mapNotNull { it.participants?.firstOrNull { id -> id != context.database.myUserId } } val friendIdsStillInFeed = if (filter in setOf(Filter.MY_FRIENDS)) {
.toSet() runCatching {
}.getOrElse { emptySet() } context.database.getFeedEntries(Int.MAX_VALUE)
.filter { it.conversationType == 0 && it.participantsSize == 2 }
.mapNotNull { it.participants?.firstOrNull { id -> id != context.database.myUserId } }
.toSet()
}.getOrElse { emptySet() }
} else {
emptySet()
}
val incomingRequestUserIds = if (filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER) { val incomingRequestUserIds = if (filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER) {
runCatching { context.database.getIncomingRequestUserIds() }.getOrElse { emptySet() } runCatching { context.database.getIncomingRequestUserIds() }.getOrElse { emptySet() }
@@ -368,10 +397,14 @@ class BulkMessagingAction : AbstractAction() {
.filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true } .filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true }
.filter { friend -> .filter { friend ->
when { when {
// Only show incoming/follower requests that exist in FriendWhoAddedMe (real pending requests)
filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER -> filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER ->
friend.userId != null && friend.userId in incomingRequestUserIds friend.userId != null && friend.userId in incomingRequestUserIds
else -> // Only apply feed filtering to MY_FRIENDS filter
filter == Filter.MY_FRIENDS ->
friendIdsStillInFeed.isEmpty() || friend.userId in friendIdsStillInFeed friendIdsStillInFeed.isEmpty() || friend.userId in friendIdsStillInFeed
// All other filters: don't restrict by feed presence
else -> true
} }
} }
.toMutableList() .toMutableList()

View File

@@ -257,6 +257,18 @@ class SendOverride : Feature("Send Override") {
} }
} }
} }
ContentType.NOTE -> {
if (stripMediaMetadata.contains("remove_audio_note_duration")) {
edit(6, 1, 1) {
remove(13)
}
}
if (stripMediaMetadata.contains("remove_audio_note_transcript_capability")) {
edit(6, 1) {
remove(3)
}
}
}
else -> {} else -> {}
} }
} }
@@ -368,6 +380,12 @@ class SendOverride : Feature("Send Override") {
event.canceled = true event.canceled = true
fun invokeOriginalAndRestoreResult(ev: SendMessageWithContentEvent) {
val result = ev.adapter.invokeOriginal()
ev.adapter.setResult(result)
ev.canceled = false
}
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean { fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get() val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get()
if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) { if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) {
@@ -412,7 +430,7 @@ class SendOverride : Feature("Send Override") {
edit(11, 5, 2) { edit(11, 5, 2) {
arrayOf(6, 7, 8).forEach { remove(it) } arrayOf(6, 7, 8).forEach { remove(it) }
addVarInt(5, messageProtoReader.getVarInt(3, 3, 5, 2, 5) ?: messageProtoReader.getVarInt(11, 5, 2, 5) ?: 1) addVarInt(5, messageProtoReader.getVarInt(3, 3, 5, 2, 5) ?: messageProtoReader.getVarInt(11, 5, 2, 5) ?: 1)
if (snapDurationMs != null) { if (snapDurationMs != null && overrideType != "SAVEABLE_SNAP") {
addVarInt(8, snapDurationMs / 1000) addVarInt(8, snapDurationMs / 1000)
if (snapDurationMs / 1000 <= 0) { if (snapDurationMs / 1000 <= 0) {
addVarInt(99, snapDurationMs) addVarInt(99, snapDurationMs)
@@ -422,53 +440,12 @@ class SendOverride : Feature("Send Override") {
} }
} }
// set app source (same as SnapEnhance - no save policy in proto for story+chat)
edit(11, 22) { edit(11, 22) {
remove(4) remove(4)
addVarInt(4, 5) addVarInt(4, 5) // APP_SOURCE_CAMERA
}
edit(11, 5) {
if (getOrNull(7) != null) {
remove(7)
}
addVarInt(7, savePolicyValue)
}
// also set at root of snap doc
edit(11) {
if (getOrNull(7) != null) {
remove(7)
}
addVarInt(7, savePolicyValue)
} }
}.toByteArray() }.toByteArray()
try {
val savePolicyEnumClass = runCatching {
XposedHelpers.findClass("com.snapchat.client.messaging.SavePolicy",
localMessageContent.instanceNonNull().javaClass.classLoader)
}.getOrNull()
if (savePolicyEnumClass != null && savePolicyEnumClass.isEnum) {
@Suppress("UNCHECKED_CAST")
val enumClass = savePolicyEnumClass as Class<out Enum<*>>
val enumName = if (overrideType == "SAVEABLE_SNAP") "LIFETIME" else "PROHIBITED"
val targetEnum = runCatching {
java.lang.Enum.valueOf(enumClass, enumName)
}.getOrNull()
if (targetEnum != null) {
val savePolicyField = localMessageContent.instanceNonNull().javaClass.declaredFields
.find { it.name == "mSavePolicy" }
if (savePolicyField != null) {
savePolicyField.isAccessible = true
XposedHelpers.setObjectField(localMessageContent.instanceNonNull(), "mSavePolicy", targetEnum)
}
}
}
} catch (e: Exception) {
context.log.warn("SendOverride: Failed to set mSavePolicy: ${e.message}")
}
} }
"NOTE" -> { "NOTE" -> {
// Check if "prevent audio" is enabled in UnsaveableMessages // Check if "prevent audio" is enabled in UnsaveableMessages
@@ -477,9 +454,15 @@ class SendOverride : Feature("Send Override") {
postSavePolicy = 1 // PROHIBITED postSavePolicy = 1 // PROHIBITED
} }
localMessageContent.contentType = ContentType.NOTE localMessageContent.contentType = ContentType.NOTE
val stripMeta = context.config.messaging.stripMediaMetadata.get()
val omitTranscript = stripMeta.contains("remove_audio_note_transcript_capability")
val rawDurationMs = messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
?: messageProtoReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
?: (context.feature(MediaFilePicker::class).lastMediaDuration ?: 0).toLong()
val durationForProto = minOf(rawDurationMs, MessageSender.VOICE_NOTE_MAX_DURATION_MS)
val audioNoteProto = MessageSender.audioNoteProto( val audioNoteProto = MessageSender.audioNoteProto(
messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: context.feature(MediaFilePicker::class).lastMediaDuration ?: 0, durationForProto,
Locale.getDefault().toLanguageTag() if (omitTranscript) null else Locale.getDefault().toLanguageTag()
) )
// Set save policy in the proto if prevent audio is enabled // Set save policy in the proto if prevent audio is enabled
@@ -543,8 +526,8 @@ class SendOverride : Feature("Send Override") {
val resolvedOverrideType = configOverrideType?.takeIf { it != "always_ask" } val resolvedOverrideType = configOverrideType?.takeIf { it != "always_ask" }
if (resolvedOverrideType != null) { if (resolvedOverrideType != null) {
if (sendMedia(resolvedOverrideType, 10)) { if (sendMedia(resolvedOverrideType, 10000)) {
event.invokeOriginal() invokeOriginalAndRestoreResult(event)
} }
return@subscribe return@subscribe
} }
@@ -953,7 +936,7 @@ class SendOverride : Feature("Send Override") {
context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100) context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100)
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
event.invokeOriginal() invokeOriginalAndRestoreResult(event)
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask" val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
context.inAppOverlay.showStatusToast( context.inAppOverlay.showStatusToast(
icon = Icons.Filled.CheckCircle, icon = Icons.Filled.CheckCircle,
@@ -1001,7 +984,7 @@ class SendOverride : Feature("Send Override") {
} }
} else { } else {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
event.invokeOriginal() invokeOriginalAndRestoreResult(event)
} }
} }
}) { }) {

View File

@@ -14,6 +14,8 @@ class MessageSender(
private val context: ModContext, private val context: ModContext,
) { ) {
companion object { companion object {
const val VOICE_NOTE_MAX_DURATION_MS = 100_000L
val audioNoteProto: (Long, String?) -> ByteArray = { duration, userLocale -> val audioNoteProto: (Long, String?) -> ByteArray = { duration, userLocale ->
ProtoWriter().apply { ProtoWriter().apply {
from(6, 1) { from(6, 1) {

View File

@@ -568,6 +568,12 @@ class InAppOverlay(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val progress = remember { Animatable(1f) } val progress = remember { Animatable(1f) }
val progressGradient = Brush.horizontalGradient(
listOf(
Color(0xFF6F28A8).copy(alpha = 0.7f),
Color(0xFF0059B7).copy(alpha = 0.7f)
)
)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
progress.animateTo( progress.animateTo(
@@ -576,10 +582,19 @@ class InAppOverlay(
) )
} }
LinearProgressIndicator( Box(
progress = { progress.value },
modifier = modifier modifier = modifier
) .height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color(0xFF6F28A8).copy(alpha = 0.25f))
) {
Box(
modifier = Modifier
.fillMaxWidth(progress.value)
.fillMaxHeight()
.background(brush = progressGradient, shape = RoundedCornerShape(2.dp))
)
}
} }
fun showStatusToast( fun showStatusToast(
@@ -688,7 +703,7 @@ class InAppOverlay(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(top = 16.dp) .padding(top = 12.dp)
.graphicsLayer { .graphicsLayer {
translationX = offsetX translationX = offsetX
translationY = offsetY translationY = offsetY
@@ -697,23 +712,33 @@ class InAppOverlay(
alpha = progress alpha = progress
} }
) { ) {
val backgroundColor = if (isWorking) Color(0xFF1B5E20).copy(alpha = 0.8f) else Color(0xFFB71C1C).copy(alpha = 0.8f) val bypassGradient = if (isWorking) Brush.horizontalGradient(
listOf(
Color(0xFF6F28A8).copy(alpha = 0.7f),
Color(0xFF0059B7).copy(alpha = 0.7f)
)
) else Brush.horizontalGradient(
listOf(
Color(0xFF8B1538).copy(alpha = 0.7f),
Color(0xFFB71C1C).copy(alpha = 0.7f)
)
)
Row( Row(
modifier = Modifier modifier = Modifier
.background( .background(
color = backgroundColor, brush = bypassGradient,
shape = MaterialTheme.shapes.large shape = MaterialTheme.shapes.medium
) )
.padding(horizontal = 20.dp, vertical = 12.dp), .padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Icon( Icon(
imageVector = if (isWorking) Icons.Filled.Check else Icons.Filled.Close, imageVector = if (isWorking) Icons.Filled.Check else Icons.Filled.Close,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(20.dp) modifier = Modifier.size(14.dp)
) )
Text( Text(
@@ -722,8 +747,8 @@ class InAppOverlay(
else else
context.translation["manager.sections.bypass_status.inactive"], context.translation["manager.sections.bypass_status.inactive"],
color = Color.White, color = Color.White,
fontSize = 15.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.SemiBold
) )
} }
} }