From 06046340c6f3fea085cf8946ad09440f999a2e4f Mon Sep 17 00:00:00 2001 From: particle-box Date: Mon, 26 Jan 2026 12:45:31 +0530 Subject: [PATCH] feat: wrong architecture detector --- .../purrfectsnap/ui/manager/MainActivity.kt | 31 + .../purrfectsnap/ui/setup/SetupActivity.kt | 28 + .../impl/messaging/PreventMessageSending.kt | 39 +- .../features/impl/messaging/SendOverride.kt | 557 +++++++++--------- .../impl/tweaks/UnsaveableMessages.kt | 117 ++-- 5 files changed, 429 insertions(+), 343 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt index e96c593f..0ac327e2 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/MainActivity.kt @@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.manager import android.app.Activity import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -10,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -22,6 +24,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.foundation.background import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues @@ -33,11 +36,14 @@ import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning import me.eternal.purrfectsnap.RemoteSideContext import me.eternal.purrfectsnap.SharedContextHolder import me.eternal.purrfectsnap.common.ui.AppMaterialTheme import me.eternal.purrfectsnap.common.ui.ThemeMode import me.eternal.purrfectsnap.common.ui.ThemePreferences +import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper import me.eternal.purrfectsnap.ui.util.ThankYouDialog @@ -113,6 +119,31 @@ class MainActivity : ComponentActivity() { } } AppMaterialTheme(themeMode = themeMode) { + val shouldShowAbiWarning = remember { + val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") } + val libDir = context.applicationInfo.nativeLibraryDir.orEmpty() + val appIsArm64 = libDir.contains("arm64") + deviceIsArm64 && !appIsArm64 + } + if (shouldShowAbiWarning) { + AestheticDialog( + onDismissRequest = {}, + title = "Wrong APK installed", + text = "", + icon = Icons.Filled.Warning, + confirmButtonText = "Close", + onConfirm = { (context as? Activity)?.finishAffinity() }, + showCloseButton = false, + opaque = true, + customContent = { + Text( + text = "Your device is armv8, please download the armv8 apk, not armv7.", + color = PurrfectPalette.textSecondary, + lineHeight = 18.sp + ) + } + ) + } val background = MaterialTheme.colorScheme.background val isLight = background.luminance() > 0.5f val view = LocalView.current diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/SetupActivity.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/SetupActivity.kt index ad62ca41..61d2d0ad 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/SetupActivity.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/setup/SetupActivity.kt @@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.setup import android.app.Activity +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler @@ -78,6 +79,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -203,6 +205,7 @@ class SetupActivity : ComponentActivity() { } setContent { + val context = LocalContext.current val navController = rememberNavController() var canGoNext by remember { mutableStateOf(false) } var lastRoute by rememberSaveable { mutableStateOf("") } @@ -214,6 +217,31 @@ class SetupActivity : ComponentActivity() { } val skipPatch by rememberSaveable { skipPatchChoice } val installMode by installModeChoice + val shouldShowAbiWarning = remember { + val deviceIsArm64 = Build.SUPPORTED_ABIS.any { it == "arm64-v8a" || it.startsWith("arm64") } + val libDir = context.applicationInfo.nativeLibraryDir.orEmpty() + val appIsArm64 = libDir.contains("arm64") + deviceIsArm64 && !appIsArm64 + } + if (shouldShowAbiWarning) { + AestheticDialog( + onDismissRequest = {}, + title = "Wrong APK installed", + text = "", + icon = Icons.Filled.Warning, + confirmButtonText = "Close", + onConfirm = { (context as? Activity)?.finishAffinity() }, + showCloseButton = false, + opaque = true, + customContent = { + Text( + text = "Your device is armv8, please download the armv8 apk, not armv7.", + color = PurrfectPalette.textSecondary, + lineHeight = 18.sp + ) + } + ) + } val visibleScreens = remember(skipPatch, installMode) { requiredScreens.filterNot { screen -> if (skipPatch && (screen is PatchSnapchatScreen || screen is RootInstallSnapchatScreen)) { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/PreventMessageSending.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/PreventMessageSending.kt index 8cd4b511..70ec986a 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/PreventMessageSending.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/PreventMessageSending.kt @@ -1,7 +1,9 @@ package me.eternal.purrfectsnap.core.features.impl.messaging +import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.NotificationType import me.eternal.purrfectsnap.common.util.protobuf.ProtoEditor +import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent @@ -26,12 +28,41 @@ class PreventMessageSending : Feature("Prevent message sending") { }.toByteArray() } - context.event.subscribe(NativeUnaryCallEvent::class, { preventMessageSending.contains("snap_replay") }) { event -> - handleUpdateContentMessage(event.uri, event.buffer)?.let { event.buffer = it } + fun handleNativeUnaryCall(uri: String, buffer: ByteArray): ByteArray? { + if (uri == "/messagingcoreservice.MessagingCoreService/UpdateContentMessage") { + return handleUpdateContentMessage(uri, buffer) + } + return null } - context.event.subscribe(UnaryCallEvent::class, { preventMessageSending.contains("snap_replay") }) { event -> - handleUpdateContentMessage(event.uri, event.buffer)?.let { event.buffer = it } + context.event.subscribe(NativeUnaryCallEvent::class) { event -> + if (event.uri == "/messagingcoreservice.MessagingCoreService/CreateContentMessage") { + val contentTypeId = ProtoReader(event.buffer).getVarInt(4, 2)?.toInt() ?: return@subscribe + val associatedType = NotificationType.fromContentType(ContentType.fromId(contentTypeId)) ?: return@subscribe + if (preventMessageSending.contains(associatedType.key) && associatedType.key != "snap_replay") { + context.log.verbose("Preventing native CreateContentMessage for $associatedType") + event.canceled = true + } + } + + if (preventMessageSending.contains("snap_replay")) { + handleNativeUnaryCall(event.uri, event.buffer)?.let { event.buffer = it } + } + } + + context.event.subscribe(UnaryCallEvent::class) { event -> + if (event.uri == "/messagingcoreservice.MessagingCoreService/CreateContentMessage") { + val contentTypeId = ProtoReader(event.buffer).getVarInt(4, 2)?.toInt() ?: return@subscribe + val associatedType = NotificationType.fromContentType(ContentType.fromId(contentTypeId)) ?: return@subscribe + if (preventMessageSending.contains(associatedType.key) && associatedType.key != "snap_replay") { + context.log.verbose("Preventing CreateContentMessage for $associatedType") + event.canceled = true + } + } + + if (preventMessageSending.contains("snap_replay")) { + handleNativeUnaryCall(event.uri, event.buffer)?.let { event.buffer = it } + } } context.classCache.conversationManager.hook("updateMessage", HookStage.BEFORE) { param -> diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt index 398811fc..a3a046a2 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/SendOverride.kt @@ -3,12 +3,8 @@ package me.eternal.purrfectsnap.core.features.impl.messaging import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -16,11 +12,9 @@ import androidx.compose.runtime.* import androidx.core.app.NotificationCompat import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -38,10 +32,7 @@ import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent import me.eternal.purrfectsnap.core.features.Feature import me.eternal.purrfectsnap.core.features.impl.experiments.MediaFilePicker -import me.eternal.purrfectsnap.core.features.impl.tweaks.UnsaveableMessages import me.eternal.purrfectsnap.core.messaging.MessageSender -import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette -import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.hook @@ -154,8 +145,7 @@ class SendOverride : Feature("Send Override") { val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get() var postSavePolicy: Int? = null - val configOverrideType = context.config.messaging.galleryMediaSendOverride.mode.getNullable() - val includeCameraSnaps = context.config.messaging.galleryMediaSendOverride.includeCameraSnaps.get() + val configOverrideType = context.config.messaging.galleryMediaSendOverride.mode.getNullable()?.toString() if (configOverrideType == null && stripMediaMetadata.isEmpty()) return context.event.subscribe(MediaUploadEvent::class) { event -> @@ -186,30 +176,21 @@ class SendOverride : Feature("Send Override") { } } if (stripMediaMetadata.contains("remove_audio_note_transcript_capability")) { - // Remove locale string (field 3) which may be used for transcript capability - // Also try other potential fields if (hasFullPath) { - edit(4, 4, 6, 1, 1) { - remove(2) // potential transcript-related field - remove(3) // locale string (may affect transcript capability) - remove(4) // potential transcript-related field - } edit(4, 4, 6, 1) { - remove(2) // possible transcript capability flag at parent level - remove(3) // locale at parent level + remove(3) // locale string } } if (hasDirectPath || !hasFullPath) { - edit(6, 1, 1) { - remove(2) // potential transcript-related field - remove(3) // locale string (may affect transcript capability) - remove(4) // potential transcript-related field - } edit(6, 1) { - remove(2) // possible transcript capability flag at parent level - remove(3) // locale at parent level (field 3 at 6,1 is locale per MessageSender) + remove(3) // locale string } } + runCatching { + val field = result.messageContent.instanceNonNull().javaClass.getDeclaredField("mAllowsTranscription") + field.isAccessible = true + field.set(result.messageContent.instanceNonNull(), false) + } } }.toByteArray() } @@ -284,27 +265,71 @@ class SendOverride : Feature("Send Override") { if (configOverrideType == null) return - context.event.subscribe(NativeUnaryCallEvent::class) { event -> + context.event.subscribe(NativeUnaryCallEvent::class, priority = 100) { event -> if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe postSavePolicy?.let { savePolicy -> context.log.verbose("postSavePolicy=$savePolicy") + val protoReader = ProtoReader(event.buffer) event.buffer = ProtoEditor(event.buffer).apply { - edit(4) { - remove(7) - addVarInt(7, savePolicy) - } + // Handle chat messages (field 4) + if (protoReader.followPath(4) != null) { + edit(4) { + remove(7) + addVarInt(7, savePolicy) + } - // remove Keep Snaps in Chat ability - if (savePolicy == 1/* PROHIBITED */) { - edit(6, 9) { - remove(1) + // remove Keep Snaps in Chat ability + if (savePolicy == 1/* PROHIBITED */) { + edit(6, 9) { + remove(1) + } + } + } + + // Handle NOTE messages (field 6) - audio notes + // Check both root level (6) and nested under media (4, 4, 6) + val noteAtRoot = protoReader.followPath(6) != null + val noteNested = protoReader.followPath(4, 4, 6) != null + + if (noteAtRoot || noteNested) { + // Set save policy in the NOTE path + val hasNestedPath = if (noteNested) { + protoReader.followPath(4, 4, 6, 1, 1) != null + } else { + protoReader.followPath(6, 1, 1) != null + } + + if (noteNested) { + if (hasNestedPath) { + edit(4, 4, 6, 1, 1) { + remove(7) + addVarInt(7, savePolicy) + } + } else { + edit(4, 4, 6, 1) { + remove(7) + addVarInt(7, savePolicy) + } + } + } else { + if (hasNestedPath) { + edit(6, 1, 1) { + remove(7) + addVarInt(7, savePolicy) + } + } else { + edit(6, 1) { + remove(7) + addVarInt(7, savePolicy) + } + } } } }.toByteArray() } } - context.event.subscribe(SendMessageWithContentEvent::class) { event -> + context.event.subscribe(SendMessageWithContentEvent::class, priority = -100) { event -> postSavePolicy = null if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe val localMessageContent = event.messageContent @@ -312,9 +337,6 @@ class SendOverride : Feature("Send Override") { if (localMessageContent.contentType != ContentType.EXTERNAL_MEDIA && localMessageContent.contentType != ContentType.SNAP && localMessageContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata") == null) return@subscribe - val isCameraSnap = localMessageContent.contentType == ContentType.SNAP && - localMessageContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata") == null - if (isCameraSnap && !includeCameraSnaps) return@subscribe //prevent story replies val messageProtoReader = ProtoReader(localMessageContent.content ?: return@subscribe) @@ -339,7 +361,8 @@ class SendOverride : Feature("Send Override") { event.canceled = true fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean { - if (overrideType != "ORIGINAL" && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) { + val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get() + if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) { context.inAppOverlay.showStatusToast( icon = Icons.Default.WarningAmber, context.translation["gallery_media_send_override.multiple_media_toast"] @@ -349,7 +372,7 @@ class SendOverride : Feature("Send Override") { when (overrideType) { "SNAP", "SAVEABLE_SNAP" -> { - val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 0 else 1 + val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 2 else 1 postSavePolicy = savePolicyValue val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer() @@ -402,6 +425,13 @@ class SendOverride : Feature("Send Override") { } addVarInt(7, savePolicyValue) } + // also set at root of snap doc + edit(11) { + if (getOrNull(7) != null) { + remove(7) + } + addVarInt(7, savePolicyValue) + } }.toByteArray() try { @@ -433,21 +463,78 @@ class SendOverride : Feature("Send Override") { } } "NOTE" -> { + // Check if "prevent audio" is enabled in UnsaveableMessages + val shouldPreventSave = context.config.messaging.unsaveableMessages.note.get() + if (shouldPreventSave) { + postSavePolicy = 1 // PROHIBITED + } localMessageContent.contentType = ContentType.NOTE - localMessageContent.content = - MessageSender.audioNoteProto( - messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: context.feature(MediaFilePicker::class).lastMediaDuration ?: 0, - Locale.getDefault().toLanguageTag() - ) + val audioNoteProto = MessageSender.audioNoteProto( + messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15) ?: context.feature(MediaFilePicker::class).lastMediaDuration ?: 0, + Locale.getDefault().toLanguageTag() + ) + + // Set save policy in the proto if prevent audio is enabled + localMessageContent.content = if (shouldPreventSave) { + // Check which path structure exists in the audio note proto + val protoReader = ProtoReader(audioNoteProto) + val hasNestedPath = protoReader.followPath(6, 1, 1) != null + + ProtoEditor(audioNoteProto).apply { + // Set save policy to PROHIBITED (1) in the NOTE path + if (hasNestedPath) { + edit(6, 1, 1) { + remove(7) + addVarInt(7, 1) + } + } else { + edit(6, 1) { + remove(7) + addVarInt(7, 1) + } + } + }.toByteArray() + } else { + audioNoteProto + } + + // Set mSavePolicy on Java object if prevent audio is enabled + if (shouldPreventSave) { + 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> + val prohibitedEnum = runCatching { + java.lang.Enum.valueOf(enumClass, "PROHIBITED") + }.getOrNull() + + if (prohibitedEnum != null) { + val savePolicyField = localMessageContent.instanceNonNull().javaClass.declaredFields + .find { it.name == "mSavePolicy" } + + if (savePolicyField != null) { + savePolicyField.isAccessible = true + XposedHelpers.setObjectField(localMessageContent.instanceNonNull(), "mSavePolicy", prohibitedEnum) + } + } + } + } catch (e: Exception) { + context.log.warn("SendOverride: Failed to set mSavePolicy for NOTE: ${e.message}") + } + } } } return true } - val overrideType = configOverrideType ?: return@subscribe - if (overrideType != "always_ask") { - if (sendMedia(overrideType, 10)) { + if (configOverrideType != null && configOverrideType != "always_ask") { + if (sendMedia(configOverrideType, 10)) { event.invokeOriginal() } return@subscribe @@ -455,200 +542,154 @@ class SendOverride : Feature("Send Override") { context.runOnUiThread { val recipientNameForTask = recipientName + val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0 + createComposeAlertDialog(context.mainActivity!!) { alertDialog -> - PurrfectOverlayTheme { - val mainTranslation = remember { - context.translation.getCategory("send_override_dialog") + val mainTranslation = remember { + context.translation.getCategory("send_override_dialog") + } + + @Composable + fun ActionTile( + modifier: Modifier = Modifier, + selected: Boolean = false, + icon: ImageVector, + title: String, + enabled: Boolean = true, + onClick: () -> Unit + ) { + Card( + modifier = modifier.then(if (!enabled) Modifier.alpha(0.5f) else Modifier), + onClick = { if (enabled) onClick() }, + elevation = if (selected) CardDefaults.elevatedCardElevation(disabledElevation = 3.dp) else CardDefaults.cardElevation(), + colors = if (selected) CardDefaults.elevatedCardColors() else CardDefaults.cardColors() + ) { + Column( + modifier = Modifier + .padding(16.dp) + .size(75.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(icon, contentDescription = title, modifier = Modifier + .size(32.dp) + .padding(4.dp)) + Text(title, modifier = Modifier.fillMaxWidth(), fontSize = 12.sp, fontWeight = FontWeight.Light, softWrap = true, lineHeight = 14.sp, textAlign = TextAlign.Center) + } } - val dialogShape = RoundedCornerShape(24.dp) - val dialogSurfaceColor = Color(0xFF2A2452) - val border = remember { - Brush.linearGradient( - listOf( - PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f), - PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f) - ) - ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val translation = remember { + context.translation.getCategory("features.options.gallery_media_send_override") } - val dialogBackground = remember { - Brush.linearGradient( - listOf( - Color(0xFF2A2452), - Color(0xFF1A143A) - ) - ) + var scheduleEnabled by remember { mutableStateOf(false) } + + Text(fontSize = 20.sp, fontWeight = FontWeight.Medium, text = "${mainTranslation["send_as"]} ${ + translation[selectedType]}", modifier = Modifier.padding(5.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ActionTile(selected = selectedType == "ORIGINAL", icon = Icons.Filled.Photo, title = translation["ORIGINAL"], enabled = true) { + selectedType = "ORIGINAL" + } + ActionTile(selected = selectedType == "SNAP" || selectedType == "SAVEABLE_SNAP", icon = Icons.Filled.PhotoCamera, title = translation["SNAP"], enabled = true) { + selectedType = "SNAP" + } + ActionTile(selected = selectedType == "NOTE", icon = Icons.Filled.MusicNote, title = translation["NOTE"], enabled = true) { + selectedType = "NOTE" + } } - @Composable - fun ActionTile( - modifier: Modifier = Modifier, - selected: Boolean = false, - icon: ImageVector, - title: String, - onClick: () -> Unit - ) { - Card( - modifier = modifier, - onClick = onClick, - shape = RoundedCornerShape(18.dp), - elevation = CardDefaults.cardElevation(defaultElevation = if (selected) 4.dp else 1.dp), - colors = CardDefaults.cardColors( - containerColor = if (selected) Color(0xFF3E3478) else Color(0xFF2F2A5B), - contentColor = Color.White - ), - border = if (selected) BorderStroke(1.dp, PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.6f)) else null - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 10.dp, vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Icon( - icon, - contentDescription = title, - modifier = Modifier.size(28.dp), - tint = if (selected) PurrfectOverlayPalette.glowSecondary else Color.White.copy(alpha = 0.9f) + fun convertDuration(duration: Float) = when { + duration in -2f..-1f -> 100 + duration in -1f..-0f -> 250 + duration in -0f..1f -> 500 + duration >= 11f -> null + else -> ((duration * 1000).toInt() / 1000) * 1000 + } + + fun formatTimeText(ms: Long): String { + val days = (ms / (24 * 60 * 60 * 1000)).toInt() + val hours = ((ms / (60 * 60 * 1000)) % 24).toInt() + val minutes = ((ms / (60 * 1000)) % 60).toInt() + val seconds = ((ms / 1000) % 60).toInt() + return buildString { + if (days > 0) append("${days}d ") + if (hours > 0 || days > 0) append("${hours}h ") + if (minutes > 0 || hours > 0 || days > 0) append("${minutes}m ") + append("${seconds}s") + } + } + + when (selectedType) { + "SNAP", "SAVEABLE_SNAP" -> { + fun toggleSaveable() { + selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP" + } + Row( + modifier = Modifier.fillMaxWidth().clickable { + toggleSaveable() + }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ){ + Checkbox( + checked = selectedType == "SAVEABLE_SNAP", + onCheckedChange = { + toggleSaveable() + } ) - Spacer(Modifier.height(6.dp)) + Text(text = mainTranslation["saveable_snap_hint"], lineHeight = 15.sp) + } + Column( + modifier = Modifier.padding(start = 8.dp) + ) { Text( - title, + text = mainTranslation.format("duration", + "duration" to (convertDuration(customDuration)?.toDuration(DurationUnit.MILLISECONDS)?.toString(DurationUnit.SECONDS, 2) ?: mainTranslation["unlimited_duration"]) + ) + ) + Slider( modifier = Modifier.fillMaxWidth(), - fontSize = 12.sp, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - softWrap = true, - lineHeight = 14.sp, - textAlign = TextAlign.Center + enabled = selectedType != "SAVEABLE_SNAP", + value = customDuration, + onValueChange = { + customDuration = it + }, + valueRange = -2f..11f, ) } } } - Surface( + Row( modifier = Modifier.fillMaxWidth(), - shape = dialogShape, - color = dialogSurfaceColor, - tonalElevation = 0.dp, - shadowElevation = 18.dp, - border = BorderStroke(1.dp, border) + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier - .background(dialogBackground, dialogShape) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val translation = remember { - context.translation.getCategory("features.options.gallery_media_send_override") + Checkbox( + checked = scheduleEnabled, + onCheckedChange = { + scheduleEnabled = it + if (!it) scheduledTime = null } - var scheduleEnabled by remember { mutableStateOf(false) } - - Text(fontSize = 20.sp, fontWeight = FontWeight.Medium, text = "Send as ${ - translation[selectedType]}", modifier = Modifier.padding(5.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - ActionTile( - modifier = Modifier.weight(1f).height(92.dp), - selected = selectedType == "ORIGINAL", - icon = Icons.Filled.Photo, - title = translation["ORIGINAL"] - ) { - selectedType = "ORIGINAL" - } - ActionTile( - modifier = Modifier.weight(1f).height(92.dp), - selected = selectedType == "SNAP" || selectedType == "SAVEABLE_SNAP", - icon = Icons.Filled.PhotoCamera, - title = translation["SNAP"] - ) { - selectedType = "SNAP" - } - ActionTile( - modifier = Modifier.weight(1f).height(92.dp), - selected = selectedType == "NOTE", - icon = Icons.Filled.MusicNote, - title = translation["NOTE"] - ) { - selectedType = "NOTE" - } - } - - fun convertDuration(duration: Float): Int? { - return when { - duration in -2f..-1f -> 100 - duration in -1f..-0f -> 250 - duration in -0f..1f -> 500 - duration >= 11f -> null - else -> ((duration * 1000).toInt() / 1000) * 1000 - } - } - - when (selectedType) { - "SNAP", "SAVEABLE_SNAP" -> { - fun toggleSaveable() { - selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP" - } - Row( - modifier = Modifier.fillMaxWidth().clickable { - toggleSaveable() - }, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ){ - Checkbox( - checked = selectedType == "SAVEABLE_SNAP", - onCheckedChange = { - toggleSaveable() - } - ) - Text(text = mainTranslation["saveable_snap_hint"], lineHeight = 15.sp) - } - Column( - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = mainTranslation.format("duration", - "duration" to (convertDuration(customDuration)?.toDuration(DurationUnit.MILLISECONDS)?.toString(DurationUnit.SECONDS, 2) ?: mainTranslation["unlimited_duration"]) - ) - ) - Slider( - modifier = Modifier.fillMaxWidth(), - enabled = selectedType != "SAVEABLE_SNAP", - value = customDuration, - onValueChange = { - customDuration = it - }, - valueRange = -2f..11f, - ) - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = scheduleEnabled, - onCheckedChange = { - scheduleEnabled = it - if (!it) scheduledTime = null - } - ) - Text(text = mainTranslation["schedule"], modifier = Modifier.weight(1f)) - if (scheduleEnabled) { - Button(onClick = { showClockPicker = true }) { - scheduledTime?.let { time -> - Text(text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(time)) - } ?: Text(context.translation["select"]) - } - } + ) + Text(text = mainTranslation["schedule"], modifier = Modifier.weight(1f)) + if (scheduleEnabled) { + Button(onClick = { showClockPicker = true }) { + scheduledTime?.let { time -> + Text(text = SimpleDateFormat("HH:mm", Locale.getDefault()).format(time)) + } ?: Text(context.translation["select"]) } + } + } if (scheduleEnabled && showClockPicker) { val datePickerState = rememberDatePickerState( @@ -662,7 +703,6 @@ class SendOverride : Feature("Send Override") { var showDatePickerDialog by remember { mutableStateOf(false) } var showTimePickerDialog by remember { mutableStateOf(false) } - // Date Picker Dialog if (showDatePickerDialog) { DatePickerDialog( onDismissRequest = { showDatePickerDialog = false }, @@ -681,7 +721,6 @@ class SendOverride : Feature("Send Override") { } } - // Time Picker Dialog if (showTimePickerDialog) { AlertDialog( onDismissRequest = { showTimePickerDialog = false }, @@ -719,7 +758,6 @@ class SendOverride : Feature("Send Override") { style = MaterialTheme.typography.titleMedium ) - // Date Selection Button OutlinedButton( onClick = { showDatePickerDialog = true }, modifier = Modifier.fillMaxWidth() @@ -727,22 +765,19 @@ class SendOverride : Feature("Send Override") { Icon(Icons.Default.CalendarToday, contentDescription = null) Spacer(Modifier.width(8.dp)) Text( - datePickerState.selectedDateMillis?.let { millis -> - SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(millis) + datePickerState.selectedDateMillis?.let { + SimpleDateFormat("dd/MM/yyyy", Locale.getDefault()).format(it) } ?: context.translation.getOrNull("select_date") ?: "Select Date" ) } - // Time Selection Button OutlinedButton( onClick = { showTimePickerDialog = true }, modifier = Modifier.fillMaxWidth() ) { Icon(Icons.Default.Schedule, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text( - String.format("%02d:%02d", clockPickerHour, clockPickerMinute) - ) + Text(String.format("%02d:%02d", clockPickerHour, clockPickerMinute)) } Row( @@ -799,6 +834,7 @@ class SendOverride : Feature("Send Override") { } Button(onClick = { alertDialog.dismiss() + val finalSelectedType = selectedType val delayMs = scheduledTime?.let { it - System.currentTimeMillis() } if (delayMs != null && delayMs > 0) { val taskHash = java.util.UUID.randomUUID().toString() @@ -810,49 +846,42 @@ class SendOverride : Feature("Send Override") { recipientNameForTask, taskHash ) - val days = (delayMs / (24 * 60 * 60 * 1000)).toInt() - val hours = ((delayMs / (60 * 60 * 1000)) % 24).toInt() - val minutes = ((delayMs / (60 * 1000)) % 60).toInt() - val seconds = ((delayMs / 1000) % 60).toInt() - - val scheduledTimeText = StringBuilder().apply { - if (days > 0) append("${days}d ") - if (hours > 0 || days > 0) append("${hours}h ") - if (minutes > 0 || hours > 0 || days > 0) append("${minutes}m ") - append("${seconds}s") - }.toString() + val scheduledTimeText = formatTimeText(delayMs) context.inAppOverlay.showStatusToast( icon = Icons.Filled.Schedule, text = context.translation.format("schedule_scheduled_for", "name" to recipientNameForTask, "time" to scheduledTimeText) ?: "Scheduled for $recipientNameForTask in $scheduledTimeText" ) val releaseBackground = acquireScheduledSendBackground() + + event.addCallbackResult("onSuccess") { + context.bridgeClient.getTaskInterface().successTask(taskHash) + } + event.addCallbackResult("onError") { + context.bridgeClient.getTaskInterface().failTask(taskHash, it.getOrNull(0)?.toString() ?: "Unknown error") + } + val job = context.coroutineScope.launch { val startTime = System.currentTimeMillis() while (true) { - delay(1000) val elapsed = System.currentTimeMillis() - startTime val remaining = delayMs - elapsed if (remaining <= 0) break - val days = (remaining / (24 * 60 * 60 * 1000)).toInt() - val hours = ((remaining / (60 * 60 * 1000)) % 24).toInt() - val minutes = ((remaining / (60 * 1000)) % 60).toInt() - val seconds = ((remaining / 1000) % 60).toInt() - val timeText = StringBuilder().apply { - if (days > 0) append("${days}d ") - if (hours > 0 || days > 0) append("${hours}h ") - if (minutes > 0 || hours > 0 || days > 0) append("${minutes}m ") - append("${seconds}s") - }.toString() + val timeText = formatTimeText(remaining) + val progress = (elapsed * 100 / delayMs).toInt().coerceIn(0, 99) + context.bridgeClient.getTaskInterface().updateTaskProgress( taskHash, context.translation.getOrNull("schedule_sending_in")?.replace("{time}", timeText) ?: "Sending in $timeText", - -1 + progress ) + delay(1000) } - if (sendMedia(selectedType, if (selectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { + + context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100) + + if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { event.invokeOriginal() - context.bridgeClient.getTaskInterface().successTask(taskHash) val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask" context.inAppOverlay.showStatusToast( icon = Icons.Filled.CheckCircle, @@ -899,7 +928,7 @@ class SendOverride : Feature("Send Override") { } } } else { - if (sendMedia(selectedType, if (selectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { + if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) { event.invokeOriginal() } } @@ -907,8 +936,6 @@ class SendOverride : Feature("Send Override") { Text(if (scheduledTime != null) mainTranslation["schedule"] else context.translation["button.send"]) } } - } - } } }.show() } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/UnsaveableMessages.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/UnsaveableMessages.kt index 0547ddaf..6b275dd5 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/UnsaveableMessages.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/UnsaveableMessages.kt @@ -15,11 +15,24 @@ class UnsaveableMessages : MessagingRuleFeature( ) { private var shouldModifySavePolicy = false + private fun getEnabledFields(): List { + val config = context.config.messaging.unsaveableMessages + return buildList { + if (config.chat.get()) add(2) + if (config.externalMedia.get()) add(3) + if (config.sticker.get()) add(4) + if (config.share.get()) add(5) + if (config.note.get()) add(6) + if (config.storyReply.get()) add(7) + if (config.snap.get()) add(11) + } + } + override fun init() { - val ruleState = context.config.rules.getRuleState(MessagingRuleType.UNSAVEABLE_MESSAGES) - if (ruleState == null) return + if (context.config.rules.getRuleState(MessagingRuleType.UNSAVEABLE_MESSAGES) == null) return context.event.subscribe(SendMessageWithContentEvent::class) { event -> + if (event.canceled) return@subscribe shouldModifySavePolicy = false if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe @@ -28,114 +41,70 @@ class UnsaveableMessages : MessagingRuleFeature( if (localMessageContent.contentType == ContentType.STATUS) return@subscribe val conversationIds = event.destinations.conversations?.map { it.toString() } ?: emptyList() - val shouldApply = if (conversationIds.isEmpty()) { - true - } else { - conversationIds.all { canUseRule(it) } - } + val shouldApply = conversationIds.isEmpty() || conversationIds.all { canUseRule(it) } if (!shouldApply) return@subscribe val messageContentBytes = localMessageContent.content ?: return@subscribe - - val config = context.config.messaging.unsaveableMessages - val enabledFields = mutableListOf() - if (config.chat.get()) enabledFields.add(2) - if (config.snap.get()) enabledFields.add(11) - if (config.externalMedia.get()) enabledFields.add(3) - if (config.sticker.get()) enabledFields.add(4) - if (config.share.get()) enabledFields.add(5) - if (config.note.get()) enabledFields.add(6) - if (config.storyReply.get()) enabledFields.add(7) - + val enabledFields = getEnabledFields() val protoReader = ProtoReader(messageContentBytes) - val fieldPath = enabledFields.firstOrNull { fieldId -> - protoReader.followPath(fieldId) != null - } ?: return@subscribe + val fieldPath = enabledFields.firstOrNull { protoReader.followPath(it) != null } ?: return@subscribe shouldModifySavePolicy = true + // Set mSavePolicy on Java object try { - val savePolicyEnumClass = runCatching { - XposedHelpers.findClass("com.snapchat.client.messaging.SavePolicy", - localMessageContent.instanceNonNull().javaClass.classLoader) - }.getOrNull() + val savePolicyEnumClass = XposedHelpers.findClass( + "com.snapchat.client.messaging.SavePolicy", + localMessageContent.instanceNonNull().javaClass.classLoader + ) - if (savePolicyEnumClass != null && savePolicyEnumClass.isEnum) { + if (savePolicyEnumClass.isEnum) { @Suppress("UNCHECKED_CAST") - val enumClass = savePolicyEnumClass as Class> - val prohibitedEnum = runCatching { - java.lang.Enum.valueOf(enumClass, "PROHIBITED") - }.getOrNull() - - if (prohibitedEnum != null) { - val savePolicyField = localMessageContent.instanceNonNull().javaClass.declaredFields - .find { it.name == "mSavePolicy" } - - if (savePolicyField != null) { - savePolicyField.isAccessible = true - XposedHelpers.setObjectField(localMessageContent.instanceNonNull(), "mSavePolicy", prohibitedEnum) - } - } + val prohibitedEnum = java.lang.Enum.valueOf( + savePolicyEnumClass as Class>, + "PROHIBITED" + ) + XposedHelpers.setObjectField(localMessageContent.instanceNonNull(), "mSavePolicy", prohibitedEnum) } } catch (e: Exception) { context.log.warn("UnsaveableMessages: Failed to set mSavePolicy: ${e.message}") } + // Modify proto bytes try { - val modifiedContent = ProtoEditor(messageContentBytes).apply { + localMessageContent.content = ProtoEditor(messageContentBytes).apply { edit(fieldPath) { - if (getOrNull(7) != null) { - remove(7) - } + remove(7) addVarInt(7, 1) } }.toByteArray() - - localMessageContent.content = modifiedContent - } catch (e: Exception) { context.log.error("UnsaveableMessages: Failed to modify savePolicy in proto: ${e.message}") } } - // Modify in NativeUnaryCallEvent as fallback + // Fallback: Modify in NativeUnaryCallEvent context.event.subscribe(NativeUnaryCallEvent::class) { event -> if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe if (!shouldModifySavePolicy) return@subscribe try { - val config = context.config.messaging.unsaveableMessages - val enabledFields = mutableListOf() - if (config.chat.get()) enabledFields.add(2) - if (config.snap.get()) enabledFields.add(11) - if (config.externalMedia.get()) enabledFields.add(3) - if (config.sticker.get()) enabledFields.add(4) - if (config.share.get()) enabledFields.add(5) - if (config.note.get()) enabledFields.add(6) - if (config.storyReply.get()) enabledFields.add(7) - + val enabledFields = getEnabledFields() val protoReader = ProtoReader(event.buffer) - val fieldPath = enabledFields.firstOrNull { fieldId -> - protoReader.followPath(fieldId) != null - } ?: return@subscribe + val fieldPath = enabledFields.firstOrNull { protoReader.followPath(it) != null } ?: return@subscribe - try { - event.buffer = ProtoEditor(event.buffer).apply { - edit(fieldPath) { - if (getOrNull(7) != null) { - remove(7) - } - addVarInt(7, 1) - } - }.toByteArray() - } catch (e: Exception) { - context.log.error("UnsaveableMessages: NativeUnaryCallEvent proto modification failed for field $fieldPath: ${e.message}") - } - shouldModifySavePolicy = false + event.buffer = ProtoEditor(event.buffer).apply { + edit(fieldPath) { + remove(7) + addVarInt(7, 1) + } + }.toByteArray() } catch (e: Exception) { context.log.error("UnsaveableMessages: NativeUnaryCallEvent modification failed: ${e.message}") + } finally { + shouldModifySavePolicy = false } } }