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.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color as AndroidColor
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.content.res.ColorStateList
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
@@ -145,8 +148,11 @@ class BulkMessagingAction : AbstractAction() {
action: suspend (id: String, setDialogMessage: (String) -> Unit) -> Unit = { _, _ -> }
) = context.coroutineScope.launch {
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) {
ViewAppearanceHelper.newAlertDialogBuilder(ctx)
val d = ViewAppearanceHelper.newAlertDialogBuilder(ctx)
.setTitle("...")
.setView(LinearLayout(ctx).apply {
val padding = (16 * ctx.resources.displayMetrics.density).toInt()
@@ -160,12 +166,28 @@ class BulkMessagingAction : AbstractAction() {
setSingleLine(false)
setPadding(0, 0, 0, spacing)
})
addView(ProgressBar(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
})
addView(progressBar)
})
.setCancelable(false)
.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 ->
@@ -350,12 +372,19 @@ class BulkMessagingAction : AbstractAction() {
val myLocation = betterLocation.locationHistory[context.database.myUserId]
withContext(Dispatchers.IO) {
val friendIdsStillInFeed = runCatching {
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() }
// Sync with Snapchat feed: only show friends whose DM conversation still exists in feed
// (FriendsFeedView excludes cleared via "clearedTimestamp < lastInteractionTimestamp")
// BUT: only apply this filtering to certain filters that specifically need it
val friendIdsStillInFeed = if (filter in setOf(Filter.MY_FRIENDS)) {
runCatching {
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) {
runCatching { context.database.getIncomingRequestUserIds() }.getOrElse { emptySet() }
@@ -368,10 +397,14 @@ class BulkMessagingAction : AbstractAction() {
.filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true }
.filter { friend ->
when {
// Only show incoming/follower requests that exist in FriendWhoAddedMe (real pending requests)
filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER ->
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
// All other filters: don't restrict by feed presence
else -> true
}
}
.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 -> {}
}
}
@@ -368,6 +380,12 @@ class SendOverride : Feature("Send Override") {
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 {
val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get()
if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) {
@@ -412,7 +430,7 @@ class SendOverride : Feature("Send Override") {
edit(11, 5, 2) {
arrayOf(6, 7, 8).forEach { remove(it) }
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)
if (snapDurationMs / 1000 <= 0) {
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) {
remove(4)
addVarInt(4, 5)
}
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)
addVarInt(4, 5) // APP_SOURCE_CAMERA
}
}.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" -> {
// Check if "prevent audio" is enabled in UnsaveableMessages
@@ -477,9 +454,15 @@ class SendOverride : Feature("Send Override") {
postSavePolicy = 1 // PROHIBITED
}
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(
messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: context.feature(MediaFilePicker::class).lastMediaDuration ?: 0,
Locale.getDefault().toLanguageTag()
durationForProto,
if (omitTranscript) null else Locale.getDefault().toLanguageTag()
)
// 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" }
if (resolvedOverrideType != null) {
if (sendMedia(resolvedOverrideType, 10)) {
event.invokeOriginal()
if (sendMedia(resolvedOverrideType, 10000)) {
invokeOriginalAndRestoreResult(event)
}
return@subscribe
}
@@ -953,7 +936,7 @@ class SendOverride : Feature("Send Override") {
context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100)
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"
context.inAppOverlay.showStatusToast(
icon = Icons.Filled.CheckCircle,
@@ -1001,7 +984,7 @@ class SendOverride : Feature("Send Override") {
}
} else {
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,
) {
companion object {
const val VOICE_NOTE_MAX_DURATION_MS = 100_000L
val audioNoteProto: (Long, String?) -> ByteArray = { duration, userLocale ->
ProtoWriter().apply {
from(6, 1) {

View File

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