v1.1.0: Stable!

This commit is contained in:
particle-box
2026-01-13 23:27:32 +05:30
parent dd27ed9450
commit 1bf6335ca2
305 changed files with 13061 additions and 5218 deletions

View File

@@ -114,6 +114,7 @@
justify-content: flex-start;
flex-wrap: nowrap;
margin: 5px 15px;
--sender-color: #ffd3b6;
}
main>.message .header {
@@ -127,16 +128,9 @@
align-items: center;
}
main>.message:nth-child(2n) .username {
color: #dcedc1;
}
main>.message:nth-child(2n + 1) .username {
color: #ffd3b6;
}
main>.message .username {
font-weight: bold;
color: var(--sender-color);
}
main>.message .time {
@@ -145,14 +139,6 @@
font-weight: 600;
}
main>.message:nth-child(2n) .content {
border-color: #dcedc1;
}
main>.message:nth-child(2n + 1) .content {
border-color: #ffd3b6;
}
main>.message .content {
background-color: var(--Snap-sigBackgroundMessageSaved);
border-left: 3px solid;
@@ -160,6 +146,7 @@
margin-top: 4px;
padding-left: 4px;
padding: 3px 0 3px 6px;
border-color: var(--sender-color);
}
main>.message .content div:has(.chat_media:not(audio):not(.overlay_media)) {
@@ -203,6 +190,17 @@
main>.message .red_snap_svg {
color: var(--Snap-sigSnapWithoutSound);
}
main>.message .time.with_deleted {
display: inline-flex;
align-items: center;
gap: 6px;
}
main>.message .deleted_icon_svg {
width: 12px;
height: 12px;
color: var(--Snap-sigTextNegative);
}
</style>
<body>
<header>
@@ -220,6 +218,9 @@
<svg class="red_snap_svg" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="5" width="10.5" height="10.5" rx="1.808" stroke="currentColor" stroke-width="1.5"></rect>
</svg>
<svg class="deleted_icon_svg" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 3h6M5 7h14M6 7l1 14h10l1-14M10 10v7M14 10v7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</div>
<script>
@@ -251,6 +252,52 @@
}
}
function hexToRgb(hex) {
if (!hex) return null
let value = hex.replace("#", "")
if (value.length === 3) {
value = value.split("").map(c => c + c).join("")
}
if (value.length !== 6) return null
const number = parseInt(value, 16)
return {
r: (number >> 16) & 255,
g: (number >> 8) & 255,
b: number & 255
}
}
function rgbToHsl(r, g, b) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
let s = 0
const l = (max + min) / 2
const delta = max - min
if (delta !== 0) {
s = delta / (1 - Math.abs(2 * l - 1))
switch (max) {
case r:
h = ((g - b) / delta) % 6
break
case g:
h = (b - r) / delta + 2
break
default:
h = (r - g) / delta + 4
break
}
h = Math.round(h * 60)
if (h < 0) h += 360
}
return { h, s: Math.round(s * 100), l: Math.round(l * 100) }
}
function makeMain() {
document.querySelector('main').innerHTML = ""
const messageTemplate = document.querySelector("#message_template")
@@ -260,9 +307,34 @@
messageList = messageList.reverse()
}
const userColors = new Map()
const userColorsOverride = conversationData.userColors || {}
const seedRgb = hexToRgb(conversationData.colorSeed)
const baseHue = seedRgb ? rgbToHsl(seedRgb.r, seedRgb.g, seedRgb.b).h : null
function colorForUser(userId) {
if (userColors.has(userId)) return userColors.get(userId)
if (userColorsOverride[userId]) {
userColors.set(userId, userColorsOverride[userId])
return userColorsOverride[userId]
}
let hash = 0
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash) + userId.charCodeAt(i)
hash |= 0
}
const hue = baseHue === null ? (Math.abs(hash) % 360) : ((baseHue + Math.abs(hash)) % 360)
const color = `hsl(${hue}, 58%, 72%)`
userColors.set(userId, color)
return color
}
messageList.forEach(message => {
const messageObject = document.createElement("div")
messageObject.classList.add("message")
const participant = participants[message.senderId]
const participantUserId = participant ? participant.userId : String(message.senderId)
messageObject.style.setProperty("--sender-color", colorForUser(String(participantUserId)))
messageObject.appendChild(((headerElement) => {
headerElement.classList.add("header")
@@ -278,6 +350,10 @@
headerElement.appendChild(((elem) => {
elem.classList.add("time")
elem.innerHTML = new Date(message.createdTimestamp).toUTCString()
if (message.isDeleted || message.type === "STATUS") {
elem.classList.add("with_deleted")
elem.appendChild(document.querySelector('.deleted_icon_svg').cloneNode(true))
}
return elem
})(document.createElement("div")))
@@ -384,4 +460,4 @@
makeHeader()
makeMain()
</script>
</body>
</body>

View File

@@ -161,7 +161,7 @@ class ModContext(
NativeConfig(
disableBitmoji = config.experimental.nativeHooks.disableBitmoji.get(),
disableMetrics = config.global.disableMetrics.get(),
composerHooks = config.experimental.nativeHooks.composerHooks.globalState == true,
valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true,
customEmojiFontPath = getCustomEmojiFontPath(this)
)
)

View File

@@ -210,7 +210,6 @@ class PurrfectSnap {
}
}
private var safeMode = false
private fun triggerMappingsGeneration() {
runCatching {
@@ -221,7 +220,7 @@ class PurrfectSnap {
}
val intent = Intent().apply {
setClassName(Constants.SE_PACKAGE_NAME, "${Constants.SE_PACKAGE_NAME}.ui.setup.SetupActivity")
setClassName(Constants.MODULE_PACKAGE_NAME, "${Constants.MODULE_PACKAGE_NAME}.ui.setup.SetupActivity")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra("requirements", 4) // Requirements.MAPPINGS = 4
}
@@ -233,24 +232,12 @@ class PurrfectSnap {
}
private fun onActivityCreate(activity: Activity) {
if (!appContext.native.verifyKey(BuildConfig.NATIVE_KEY)) {
safeMode = true
}
measureTimeMillis {
with(appContext) {
features.onActivityCreate(activity)
inAppOverlay.onActivityCreate(activity)
scriptRuntime.eachModule { callFunction("module.onSnapMainActivityCreate", activity) }
actionManager.onActivityCreate()
val isTestModeEnabled = appContext.bridgeClient.getDebugProp("test_mode", "false") == "true"
if (safeMode && !isTestModeEnabled) {
appContext.inAppOverlay.showStatusToast(
Icons.Outlined.Cancel,
"Failed to load security features! Snapchat may not work properly.",
durationMs = 3000
)
}
}
}.also { time ->
appContext.log.verbose("onActivityCreate took $time")
@@ -268,7 +255,6 @@ class PurrfectSnap {
}
val lateInit = appContext.native.initOnce {
verifyKey(BuildConfig.NATIVE_KEY)
nativeUnaryCallCallback = { request ->
appContext.event.post(NativeUnaryCallEvent(request.uri, request.buffer)) {
request.buffer = buffer
@@ -453,10 +439,26 @@ class PurrfectSnap {
}
}
val stringResources = strings(androidx.compose.material3.R.string::class, androidx.compose.ui.R.string::class)
fun resolveComposeString(key: Int): String? {
val name = stringResources[key]?.replaceFirst("m3c_", "") ?: return null
return appContext.translation.getOrNull("material3_strings.${name}") ?: ""
}
fun resolveInvalidString(resources: Resources, key: Int): String? {
val type = runCatching { resources.getResourceTypeName(key) }.getOrNull() ?: return ""
return if (type == "string") null else ""
}
Resources::class.java.getMethod("getString", Int::class.javaPrimitiveType).hook(HookStage.BEFORE) { param ->
val key = param.arg<Int>(0)
val name = stringResources[key]?.replaceFirst("m3c_", "") ?: return@hook
param.setResult(appContext.translation.getOrNull("material3_strings.${name}") ?: "")
resolveComposeString(key)?.let { param.setResult(it); return@hook }
resolveInvalidString(param.thisObject() as Resources, key)?.let { param.setResult(it) }
}
Resources::class.java.getMethod("getText", Int::class.javaPrimitiveType).hook(HookStage.BEFORE) { param ->
val key = param.arg<Int>(0)
resolveComposeString(key)?.let { param.setResult(it); return@hook }
resolveInvalidString(param.thisObject() as Resources, key)?.let { param.setResult(it) }
}
}
}

View File

@@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -36,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -144,13 +147,20 @@ class BulkMessagingAction : AbstractAction() {
ViewAppearanceHelper.newAlertDialogBuilder(ctx)
.setTitle("...")
.setView(LinearLayout(ctx).apply {
val padding = (16 * ctx.resources.displayMetrics.density).toInt()
val spacing = (8 * ctx.resources.displayMetrics.density).toInt()
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setPadding(padding, padding, padding, padding)
addView(statusTextView.apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
textAlignment = View.TEXT_ALIGNMENT_CENTER
setSingleLine(false)
setPadding(0, 0, 0, spacing)
})
addView(ProgressBar(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)
})
addView(ProgressBar(ctx))
})
.setCancelable(false)
.show()
@@ -184,21 +194,71 @@ class BulkMessagingAction : AbstractAction() {
onConfirm: () -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(text = translation["confirmation_dialog.title"]) },
text = { Text(text = translation["confirmation_dialog.message"]) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = context.translation["button.positive"])
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text(text = context.translation["button.negative"])
Dialog(onDismissRequest = onCancel) {
val shape = RoundedCornerShape(22.dp)
Surface(
shape = shape,
color = Color.Transparent,
tonalElevation = 0.dp,
shadowElevation = 16.dp,
border = BorderStroke(1.dp, BulkMessagingPalette.glowStroke)
) {
Column(
modifier = Modifier
.background(BulkMessagingPalette.cardOverlay, shape)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
shape = RoundedCornerShape(16.dp),
color = BulkMessagingPalette.faintSurface
) {
Icon(
Icons.Default.WarningAmber,
contentDescription = null,
tint = BulkMessagingPalette.textPrimary,
modifier = Modifier.padding(10.dp)
)
}
Text(
text = translation["confirmation_dialog.title"],
style = MaterialTheme.typography.titleLarge,
color = BulkMessagingPalette.textPrimary,
textAlign = TextAlign.Center
)
Text(
text = translation["confirmation_dialog.message"],
style = MaterialTheme.typography.bodyMedium,
color = BulkMessagingPalette.textSecondary,
textAlign = TextAlign.Center
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally)
) {
Button(
onClick = onCancel,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = BulkMessagingPalette.textPrimary
)
) {
Text(text = context.translation["button.negative"])
}
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = BulkMessagingPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = BulkMessagingPalette.textPrimary
)
) {
Text(text = context.translation["button.positive"])
}
}
}
}
)
}
}
private fun filterFriends(friends: List<FriendInfo>, filter: Filter, nameFilter: String): List<FriendInfo> {
@@ -728,7 +788,7 @@ class BulkMessagingAction : AbstractAction() {
ConversationType.FRIENDS_ONLY -> translation["no_friends_found"]
ConversationType.GROUPS_ONLY -> translation["no_groups_found"]
ConversationType.BOTH -> translation["no_friends_or_groups_found"]
}, fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
}, fontSize = 12.sp, fontWeight = FontWeight.Light, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = BulkMessagingPalette.textPrimary)
}
}
items(friends, key = { it.userId!! }) { friendInfo ->
@@ -1215,7 +1275,12 @@ class BulkMessagingAction : AbstractAction() {
) {
actionsList.forEach { (textBuilder, actionFunction) ->
DropdownMenuItem(
text = { Text(text = remember(selectedFriends.size, selectedGroups.size) { textBuilder() }) },
text = {
Text(
text = remember(selectedFriends.size, selectedGroups.size) { textBuilder() },
color = BulkMessagingPalette.textPrimary
)
},
onClick = {
actionsMenuExpanded = false
showConfirmationDialog = true

View File

@@ -1,12 +1,15 @@
package me.eternal.purrfectsnap.core.action.impl
import android.app.AlertDialog
import android.content.DialogInterface
import android.graphics.Color as AndroidColor
import android.net.Uri
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -31,6 +34,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalFocusManager
@@ -45,13 +49,23 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import com.github.skydoves.colorpicker.compose.BrightnessSlider
import com.github.skydoves.colorpicker.compose.ColorPickerController
import com.github.skydoves.colorpicker.compose.HsvColorPicker
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
import me.eternal.purrfectsnap.common.database.impl.FriendFeedEntry
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggerWrapper
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.core.action.AbstractAction
@@ -60,14 +74,21 @@ import me.eternal.purrfectsnap.core.logger.CoreLogger
import me.eternal.purrfectsnap.core.messaging.ConversationExporter
import me.eternal.purrfectsnap.core.messaging.ExportFormat
import me.eternal.purrfectsnap.core.messaging.ExportParams
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.wrapper.impl.Message
import java.io.File
import kotlin.math.absoluteValue
private data class ExportColorParticipant(
val userId: String,
val displayName: String,
val username: String
)
class ExportChatMessages : AbstractAction() {
private val translation by lazy { context.translation.getCategory("chat_export") }
private val dialogLogs = mutableListOf<String>()
private var dialogTitle by mutableStateOf("")
private var dialogText by mutableStateOf("")
private var currentActionDialog: AlertDialog? = null
private val dialogBackground = Brush.verticalGradient(
listOf(
@@ -88,18 +109,59 @@ class ExportChatMessages : AbstractAction() {
)
)
private data class ExportTarget(
val outputFile: File,
val finalize: (File) -> String
)
private fun resolveExportTarget(fileName: String, mimeType: String): ExportTarget {
val configuredFolder = context.config.downloader.saveFolder.get()?.trim().orEmpty()
val defaultTarget = {
val publicFolder = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"PurrfectSnap"
).also { if (!it.exists()) it.mkdirs() }
val outputFile = publicFolder.resolve(fileName).also { if (it.exists()) it.delete() }
ExportTarget(outputFile) { file -> file.absolutePath }
}
if (configuredFolder.isBlank()) {
return defaultTarget()
}
val outputFolder = runCatching {
DocumentFile.fromTreeUri(context.androidContext, Uri.parse(configuredFolder))
}.getOrNull()
if (outputFolder == null || !outputFolder.canWrite()) {
return defaultTarget()
}
val tempFile = File(context.androidContext.cacheDir, fileName).also {
if (it.exists()) it.delete()
}
return ExportTarget(tempFile) { file ->
val outputFile = outputFolder.createFile(mimeType, fileName)
?: throw IllegalStateException("Failed to create export file")
context.androidContext.contentResolver.openOutputStream(outputFile.uri)?.use { out ->
file.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to write export file")
outputFile.uri.toString()
}
}
private fun logDialog(message: String) {
context.runOnUiThread {
if (dialogLogs.size > 10) dialogLogs.removeAt(0)
dialogLogs.add(message)
context.log.debug("dialog: $message", "ExportChatMessages")
currentActionDialog!!.setMessage(dialogLogs.joinToString("\n"))
dialogText = dialogLogs.joinToString("\n")
}
}
private fun setStatus(message: String) {
context.runOnUiThread {
currentActionDialog!!.setTitle(message)
dialogTitle = message
}
}
@@ -119,6 +181,10 @@ class ExportChatMessages : AbstractAction() {
var showConversationPicker by remember { mutableStateOf(false) }
var showFormatPicker by remember { mutableStateOf(false) }
var showMessageTypePicker by remember { mutableStateOf(false) }
val colorOverrides = remember { mutableStateMapOf<String, String>() }
var colorPickerTarget by remember { mutableStateOf<ExportColorParticipant?>(null) }
var colorPickerValue by remember { mutableStateOf<Color?>(null) }
var participants by remember { mutableStateOf<List<ExportColorParticipant>>(emptyList()) }
val allFriends by rememberAsyncMutableState(null) { context.database.getAllFriends().associateBy { it.userId!! } }
val myUserId = context.database.myUserId
val focusManager = LocalFocusManager.current
@@ -272,6 +338,66 @@ class ExportChatMessages : AbstractAction() {
accent = accent
)
SectionLabel("Participant colors")
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 260.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(18.dp))
.verticalScroll(rememberScrollState())
.padding(12.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (participants.isEmpty()) {
BasicText(
text = "Select conversations to customize participant colors.",
style = TextStyle(color = Color(0xFFB1B4D7), fontSize = 12.sp)
)
} else {
participants.forEach { participant ->
val colorHex = colorOverrides[participant.userId]
val color = colorHex?.let { Color(AndroidColor.parseColor(it)) }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.05f))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(14.dp))
.clickable {
colorPickerTarget = participant
colorPickerValue = color
}
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorSwatch(color = color)
Column(modifier = Modifier.weight(1f)) {
BasicText(
text = participant.displayName,
style = TextStyle(color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
BasicText(
text = participant.username,
style = TextStyle(color = Color(0xFFB1B4D7), fontSize = 12.sp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
BasicText(
text = colorHex ?: "Auto",
style = TextStyle(color = Color(0xFFB1B4D7), fontSize = 11.sp)
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
@@ -284,15 +410,17 @@ class ExportChatMessages : AbstractAction() {
PrimaryButton(
text = translation["dialog_positive_button"],
modifier = Modifier.weight(1f),
enabled = selectedFeedEntries.isNotEmpty(),
enabled = selectedFeedEntries.isNotEmpty() || feedEntries.isNotEmpty(),
onClick = {
val selection = if (selectedFeedEntries.isEmpty()) feedEntries else selectedFeedEntries
exportChatForConversations(
selectedFeedEntries,
selection,
ExportParams(
exportFormat = exportType,
messageTypeFilter = messageTypeFilter.takeIf { it.isNotEmpty() },
amountOfMessages = amountOfMessages.takeIf { it != -1 },
downloadMedias = downloadMedias
downloadMedias = downloadMedias,
colorOverrides = colorOverrides.takeIf { it.isNotEmpty() }?.toMap()
)
)
}
@@ -307,6 +435,56 @@ class ExportChatMessages : AbstractAction() {
}
}
LaunchedEffect(selectedFeedEntries.toList(), allFriends) {
withContext(Dispatchers.IO) {
val selection = selectedFeedEntries.toList()
if (selection.isEmpty()) {
withContext(Dispatchers.Main) {
participants = emptyList()
}
return@withContext
}
val participantsMap = linkedMapOf<String, ExportColorParticipant>()
selection.forEach { entry ->
val userIds = context.database.getConversationParticipants(entry.key!!, useCache = false) ?: emptyList()
userIds.forEach { userId ->
if (participantsMap.containsKey(userId)) return@forEach
val friend = allFriends?.get(userId) ?: context.database.getFriendInfo(userId)
val displayName = friend?.displayName ?: friend?.mutableUsername ?: userId
val username = friend?.mutableUsername ?: userId
participantsMap[userId] = ExportColorParticipant(userId, displayName, username)
}
}
withContext(Dispatchers.Main) {
participants = participantsMap.values.toList()
}
}
}
colorPickerTarget?.let { target ->
Dialog(
onDismissRequest = { colorPickerTarget = null },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
ExportColorPickerDialog(
participant = target,
initialColor = colorPickerValue,
onSave = { color ->
if (color == null) {
colorOverrides.remove(target.userId)
} else {
colorOverrides[target.userId] = colorToHex(color)
}
colorPickerTarget = null
},
onClear = {
colorOverrides.remove(target.userId)
colorPickerTarget = null
}
)
}
}
if (showConversationPicker) {
Box(
modifier = Modifier
@@ -340,6 +518,31 @@ class ExportChatMessages : AbstractAction() {
}
)
}
if (feedEntries.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
SecondaryButton(
text = t("text_field_selection_all"),
modifier = Modifier.weight(1f),
onClick = {
selectedFeedEntries.clear()
selectedFeedEntries.addAll(feedEntries)
}
)
SecondaryButton(
text = translation["dialog_negative_button"],
modifier = Modifier.weight(1f),
onClick = { selectedFeedEntries.clear() }
)
}
}
}
}
}
}
@@ -686,6 +889,190 @@ class ExportChatMessages : AbstractAction() {
}
}
@Composable
private fun ExportProgressDialog(
onCancel: () -> Unit
) {
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxWidth()
.background(dialogBackground)
.padding(12.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.border(1.2.dp, accentGradient, RoundedCornerShape(26.dp)),
shape = RoundedCornerShape(26.dp),
tonalElevation = 0.dp,
color = Color.White.copy(alpha = 0.04f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(panelOverlay)
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = dialogTitle,
style = MaterialTheme.typography.titleMedium.copy(
color = Color.White,
fontWeight = FontWeight.ExtraBold
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 280.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(18.dp))
.verticalScroll(scrollState)
.padding(12.dp)
) {
BasicText(
text = dialogText,
style = TextStyle(color = Color(0xFFD9D3FF), fontSize = 12.sp)
)
}
SecondaryButton(
text = translation["dialog_negative_button"],
modifier = Modifier.fillMaxWidth(),
onClick = onCancel
)
}
}
}
}
@Composable
private fun ColorSwatch(color: Color?) {
Box(
modifier = Modifier
.size(26.dp)
.clip(RoundedCornerShape(8.dp))
.background(color ?: Color.White.copy(alpha = 0.1f))
.border(1.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
if (color == null) {
BasicText(
text = "A",
style = TextStyle(color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
)
}
}
}
private fun colorToHex(color: Color): String {
return String.format("#%06X", 0xFFFFFF and color.toArgb())
}
@Composable
private fun ExportColorPickerDialog(
participant: ExportColorParticipant,
initialColor: Color?,
onSave: (Color?) -> Unit,
onClear: () -> Unit
) {
var currentColor by remember { mutableStateOf(initialColor ?: Color.White) }
val controller = remember { ColorPickerController().apply { selectByColor(currentColor, false) } }
var colorHexValue by remember { mutableStateOf(colorToHex(currentColor).removePrefix("#")) }
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
shape = RoundedCornerShape(24.dp),
color = Color.White.copy(alpha = 0.06f),
tonalElevation = 0.dp,
shadowElevation = 16.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
Color(0xFF8C7BFF).copy(alpha = 0.6f),
Color(0xFF5FD8FF).copy(alpha = 0.5f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF1B1636))
.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Color for ${participant.displayName}",
style = MaterialTheme.typography.titleMedium.copy(
color = Color.White,
fontWeight = FontWeight.ExtraBold
)
)
TextField(
value = colorHexValue,
onValueChange = { value ->
colorHexValue = value
runCatching {
val parsed = Color(AndroidColor.parseColor("#$value"))
currentColor = parsed
controller.selectByColor(parsed, true)
}
},
label = { Text(text = "Hex Color") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.White.copy(alpha = 0.08f),
unfocusedContainerColor = Color.White.copy(alpha = 0.05f),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = Color(0xFF8EF0F3),
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
)
HsvColorPicker(
modifier = Modifier
.fillMaxWidth()
.height(240.dp),
controller = controller,
onColorChanged = {
if (!it.fromUser) return@HsvColorPicker
currentColor = it.color
colorHexValue = colorToHex(it.color).removePrefix("#")
}
)
BrightnessSlider(
modifier = Modifier
.fillMaxWidth()
.height(30.dp),
controller = controller
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
SecondaryButton(
text = "Auto",
modifier = Modifier.weight(1f),
onClick = onClear
)
PrimaryButton(
text = "Save",
modifier = Modifier.weight(1f),
onClick = { onSave(currentColor) }
)
}
}
}
}
override fun run() {
context.coroutineScope.launch(Dispatchers.Main) {
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
@@ -722,18 +1109,14 @@ class ExportChatMessages : AbstractAction() {
) {
dialogLogs.clear()
val jobs = mutableListOf<Job>()
dialogTitle = translation["exporting_chats"]
dialogText = ""
currentActionDialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(translation["exporting_chats"])
.setCancelable(false)
.setMessage("")
.create()
val conversationSize = translation.format("processing_chats", "amount" to conversations.size.toString())
logDialog(conversationSize)
context.coroutineScope.launch {
val exportJob = context.coroutineScope.launch {
conversations.forEach { conversation ->
launch {
runCatching {
@@ -747,17 +1130,33 @@ class ExportChatMessages : AbstractAction() {
}
jobs.joinAll()
logDialog(translation["finished"])
}.also {
currentActionDialog?.setButton(DialogInterface.BUTTON_POSITIVE, translation["dialog_negative_button"]) { dialog, _ ->
it.cancel()
jobs.forEach { it.cancel() }
dialog.dismiss()
}
}
currentActionDialog!!.also {
it.setCanceledOnTouchOutside(false)
}.show()
currentActionDialog = createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
ExportProgressDialog {
exportJob.cancel()
jobs.forEach { it.cancel() }
alertDialog.dismiss()
}
}.apply {
setCanceledOnTouchOutside(false)
show()
}
}
private fun fetchLoggerMessages(conversationId: String): List<LoggedMessage> {
return runCatching {
val loggerWrapper = LoggerWrapper(context.androidContext)
val messages = mutableListOf<LoggedMessage>()
var fromTimestamp = Long.MAX_VALUE
while (true) {
val batch = loggerWrapper.fetchMessages(conversationId, fromTimestamp, 500, reverseOrder = true)
if (batch.isEmpty()) break
messages.addAll(batch)
fromTimestamp = batch.last().sendTimestamp
}
messages
}.getOrDefault(emptyList())
}
private suspend fun exportFullConversation(
@@ -771,35 +1170,59 @@ class ExportChatMessages : AbstractAction() {
context.database.getFriendInfo(it)
}?.associateBy { it.userId!! } ?: emptyMap()
val loggerMessages = fetchLoggerMessages(conversationId)
val participantMap = conversationParticipants.toMutableMap().apply {
loggerMessages.forEach { message ->
if (containsKey(message.userId)) return@forEach
this[message.userId] = FriendInfo(
userId = message.userId,
displayName = message.username,
username = message.username,
usernameForSorting = message.username
)
}
}
val conversationName = feedEntry.feedDisplayName ?: conversationParticipants.values.take(3).joinToString("_") { it.mutableUsername ?: "" }
val publicFolder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "PurrfectSnap").also { if (!it.exists()) it.mkdirs() }
val outputFile = publicFolder.resolve("conversation_${conversationName}_${System.currentTimeMillis()}.${exportParams.exportFormat.extension}")
val outputName = "conversation_${conversationName}_${System.currentTimeMillis()}.${exportParams.exportFormat.extension}"
val mimeType = when (exportParams.exportFormat) {
ExportFormat.JSON -> "application/json"
ExportFormat.TEXT -> "text/plain"
ExportFormat.HTML -> "text/html"
}
val outputTarget = resolveExportTarget(outputName, mimeType)
val outputFile = outputTarget.outputFile
logDialog(translation.format("exporting_message", "conversation" to conversationName))
val conversationExporter = ConversationExporter(
context = context,
friendFeedEntry = feedEntry,
conversationParticipants = conversationParticipants,
conversationParticipants = participantMap,
exportParams = exportParams,
cacheFolder = publicFolder.resolve("cache").also { if (!it.exists()) it.mkdirs() },
cacheFolder = context.androidContext.cacheDir.resolve("chat_export").also { if (!it.exists()) it.mkdirs() },
outputFile = outputFile,
).apply { init(); printLog = {
logDialog(it.toString())
} }
var foundMessageCount = 0
val exportedOrderKeys = mutableSetOf<Long>()
var lastMessageId = fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also {
conversationExporter.readMessage(it)
var lastMessageId: Long? = null
fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also { message ->
conversationExporter.readMessage(message)
foundMessageCount++
}?.messageDescriptor?.messageId ?: run {
logDialog(translation["no_messages_found"])
return
message.orderKey?.let { exportedOrderKeys.add(it) }
lastMessageId = message.messageDescriptor?.messageId
}
while (true) {
if (lastMessageId == null) {
logDialog(translation["no_messages_found"])
}
while (lastMessageId != null) {
val fetchedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500).toMutableList()
if (fetchedMessages.isEmpty()) break
@@ -813,28 +1236,49 @@ class ExportChatMessages : AbstractAction() {
}
}
foundMessageCount += fetchedMessages.size
val remainingLimit = exportParams.amountOfMessages?.let { it - foundMessageCount } ?: Int.MAX_VALUE
if (remainingLimit <= 0) break
if (exportParams.amountOfMessages != null && foundMessageCount >= exportParams.amountOfMessages) {
fetchedMessages.reversed().subList(0, exportParams.amountOfMessages - (foundMessageCount - fetchedMessages.size)).forEach { message ->
conversationExporter.readMessage(message)
}
break
val messagesToWrite = fetchedMessages.reversed().let { messages ->
if (messages.size <= remainingLimit) messages else messages.subList(0, remainingLimit)
}
fetchedMessages.reversed().forEach { message ->
messagesToWrite.forEach { message ->
conversationExporter.readMessage(message)
foundMessageCount++
message.orderKey?.let { exportedOrderKeys.add(it) }
}
setStatus("Exporting (found ${foundMessageCount})")
}
if (loggerMessages.isNotEmpty() && (exportParams.amountOfMessages == null || foundMessageCount < exportParams.amountOfMessages)) {
val parsedLoggerMessages = loggerMessages.mapNotNull { conversationExporter.parseLoggedMessage(it) }
for (loggedMessage in parsedLoggerMessages.asReversed()) {
if (exportedOrderKeys.contains(loggedMessage.orderKey)) continue
val filter = exportParams.messageTypeFilter
if (filter != null && !filter.contains(loggedMessage.contentType)) continue
if (exportParams.amountOfMessages != null && foundMessageCount >= exportParams.amountOfMessages) break
conversationExporter.readLoggedMessage(loggedMessage)
foundMessageCount++
}
}
if (exportParams.exportFormat == ExportFormat.HTML) conversationExporter.awaitDownload()
conversationExporter.close()
logDialog(translation["writing_output"])
dialogLogs.clear()
val exportedPath = runCatching { outputTarget.finalize(outputFile) }.getOrElse { error ->
logDialog("Failed to write export output")
logDialog(error.toString())
context.log.error("Failed to finalize chat export", error)
return
}
if (outputFile.parentFile == context.androidContext.cacheDir) {
outputFile.delete()
}
logDialog("\n" + translation.format("exported_to",
"path" to outputFile.absolutePath.toString()
"path" to exportedPath
) + "\n")
}
}

View File

@@ -2,7 +2,9 @@ package me.eternal.purrfectsnap.core.action.impl
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteDatabase.OpenParams
import android.net.Uri
import android.os.Environment
import androidx.documentfile.provider.DocumentFile
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -100,6 +102,49 @@ class ExportMemories : AbstractAction() {
get() = storyTitle.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim().replace(Regex("\\s+"), "_")
}
private data class ExportTarget(
val outputFile: File,
val finalize: (File) -> String
)
private fun resolveExportTarget(fileName: String, mimeType: String): ExportTarget {
val configuredFolder = context.config.downloader.saveFolder.get()?.trim().orEmpty()
val defaultTarget = {
val documentsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
val outputDir = documentsDir.takeIf { it.exists() || it.mkdirs() }
?: context.androidContext.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS)
?: context.androidContext.filesDir
val outputFile = File(outputDir, fileName).also {
if (it.exists()) it.delete()
}
ExportTarget(outputFile) { file -> file.absolutePath }
}
if (configuredFolder.isBlank()) {
return defaultTarget()
}
val outputFolder = runCatching {
DocumentFile.fromTreeUri(context.androidContext, Uri.parse(configuredFolder))
}.getOrNull()
if (outputFolder == null || !outputFolder.canWrite()) {
return defaultTarget()
}
val tempFile = File(context.androidContext.cacheDir, fileName).also {
if (it.exists()) it.delete()
}
return ExportTarget(tempFile) { file ->
val outputFile = outputFolder.createFile(mimeType, fileName)
?: throw IllegalStateException("Failed to create export file")
context.androidContext.contentResolver.openOutputStream(outputFile.uri)?.use { out ->
file.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to write export file")
outputFile.uri.toString()
}
}
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalEncodingApi::class)
private suspend fun exportMemories(
scope: CoroutineScope = context.coroutineScope,
@@ -111,9 +156,11 @@ class ExportMemories : AbstractAction() {
) {
val downloadContext = Dispatchers.IO.limitedParallelism(10)
val writeToZipContext = Dispatchers.IO.limitedParallelism(1)
val outputZip = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "memories_" + System.currentTimeMillis() + ".zip").also {
if (it.exists()) it.delete()
}
val outputTarget = resolveExportTarget(
"memories_${System.currentTimeMillis()}.zip",
"application/zip"
)
val outputZip = outputTarget.outputFile
val okHttpClient = OkHttpClient.Builder().build()
val outputZipFile = withContext(Dispatchers.IO) {
ZipOutputStream(FileOutputStream(outputZip)).apply {
@@ -249,7 +296,16 @@ class ExportMemories : AbstractAction() {
withContext(Dispatchers.IO) {
outputZipFile.close()
}
context.longToast("Exported to ${outputZip.absolutePath}")
val exportedPath = runCatching { outputTarget.finalize(outputZip) }
.getOrElse { error ->
context.log.error("Failed to finalize memories export", error)
context.longToast("Failed to export memories")
return
}
if (outputZip.parentFile == context.androidContext.cacheDir) {
outputZip.delete()
}
context.longToast("Exported to $exportedPath")
}
@OptIn(ExperimentalMaterial3Api::class)

View File

@@ -1,6 +1,8 @@
package me.eternal.purrfectsnap.core.action.impl
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
@@ -12,9 +14,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
@@ -26,25 +30,48 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import me.eternal.purrfectsnap.common.data.FriendLinkType
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.action.AbstractAction
import me.eternal.purrfectsnap.core.event.events.impl.ActivityResultEvent
import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie
import me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver
import me.eternal.purrfectsnap.core.features.impl.experiments.AddFriendSourceSpoof
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.wrapper.impl.Snapchatter
import kotlin.random.Random
class ManageFriendList : AbstractAction() {
companion object {
private var openSuggestedOnLaunch = false
@Synchronized
fun requestOpenSuggestedOnLaunch() {
openSuggestedOnLaunch = true
}
@Synchronized
private fun consumeOpenSuggestedOnLaunch(): Boolean {
val shouldOpen = openSuggestedOnLaunch
openSuggestedOnLaunch = false
return shouldOpen
}
}
private val translation by lazy { context.translation.getCategory("friend_list") }
private val dialogBackground = Brush.verticalGradient(
listOf(
@@ -216,6 +243,22 @@ class ManageFriendList : AbstractAction() {
}
}
private fun loadSuggestedFriends(
coroutineScope: CoroutineScope,
onLoaded: (List<String>) -> Unit
) {
coroutineScope.launch(Dispatchers.IO) {
val blacklist = getUserIdBlacklist()
val suggestedFriends = context.database.getAllFriends()
.filter { it.userId !in blacklist && it.friendLinkType == FriendLinkType.SUGGESTED.value }
.sortedByDescending { it.addedTimestamp }
.mapNotNull { it.userId }
withContext(Dispatchers.Main) {
onLoaded(suggestedFriends)
}
}
}
override fun onActivityCreate() {
context.event.subscribe(ActivityResultEvent::class) { event ->
if (event.requestCode == pendingPickerAction?.first) {
@@ -254,12 +297,27 @@ class ManageFriendList : AbstractAction() {
}
private val userIdToSnapchatter = mutableMapOf<String, Snapchatter>()
private fun getUserIdBlacklist() = arrayOf(
context.database.myUserId,
"b42f1f70-5a8b-4c53-8c25-34e7ec9e6781",
"84ee8839-3911-492d-8b94-72dd80f3713a",
)
@Composable
private fun ManagerDialog() {
val pendingFriendRequests = remember { mutableStateMapOf<String, Job>() }
var fetchedFriends by remember { mutableStateOf<List<String>?>(null) }
val coroutineScope = rememberCoroutineScope()
val openSuggestedOnLaunch = remember { consumeOpenSuggestedOnLaunch() }
val bitmojiCache = remember { me.eternal.purrfectsnap.core.util.EvictingMap<String, Bitmap>(50) }
val noBitmojiBitmap = remember { BitmapFactory.decodeResource(context.resources, android.R.drawable.ic_menu_report_image).asImageBitmap() }
LaunchedEffect(openSuggestedOnLaunch) {
if (openSuggestedOnLaunch) {
loadSuggestedFriends(coroutineScope) { fetchedFriends = it }
}
}
Box(
modifier = Modifier
@@ -387,8 +445,30 @@ class ManageFriendList : AbstractAction() {
)
}
}
PrimaryButton(
text = "Load Suggested Friends",
modifier = Modifier.fillMaxWidth()
) {
loadSuggestedFriends(coroutineScope) { fetchedFriends = it }
}
}
} else {
var searchQuery by remember { mutableStateOf("") }
val filteredFriends = remember(fetchedFriends, searchQuery) {
val friends = fetchedFriends ?: emptyList()
if (searchQuery.isBlank()) {
friends.sortedByDescending { context.database.getFriendInfo(it)?.addedTimestamp ?: 0L }
} else {
friends.filter { userId ->
val friendInfo = context.database.getFriendInfo(userId)
friendInfo?.mutableUsername?.contains(searchQuery, ignoreCase = true) == true ||
friendInfo?.displayName?.contains(searchQuery, ignoreCase = true) == true ||
userId.contains(searchQuery, ignoreCase = true)
}.sortedByDescending { context.database.getFriendInfo(it)?.addedTimestamp ?: 0L }
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -411,9 +491,10 @@ class ManageFriendList : AbstractAction() {
.clickable { fetchedFriends = null },
contentAlignment = Alignment.Center
) {
Image(
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = context.translation["common.back"],
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
@@ -427,16 +508,45 @@ class ManageFriendList : AbstractAction() {
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
Text(
text = translation.get("export_description"),
color = Color(0xFFD9D3FF),
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.size(46.dp))
}
BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.06f))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 14.sp),
cursorBrush = SolidColor(Color(0xFF8EF0F3)),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search",
tint = Color(0xFFB1B4D7),
modifier = Modifier.size(18.dp)
)
Box(Modifier.weight(1f)) {
if (searchQuery.isEmpty()) {
BasicText(
"Search...",
style = androidx.compose.ui.text.TextStyle(color = Color(0xFFB1B4D7), fontSize = 14.sp)
)
}
innerTextField()
}
}
}
)
Surface(
modifier = Modifier
@@ -453,7 +563,7 @@ class ManageFriendList : AbstractAction() {
.padding(12.dp)
) {
item {
if (fetchedFriends?.isEmpty() == true) {
if (filteredFriends.isEmpty()) {
BasicText(
context.translation["common.no_friends_found"],
style = androidx.compose.ui.text.TextStyle(color = Color(0xFFA8B5D1), fontSize = 13.sp),
@@ -461,47 +571,70 @@ class ManageFriendList : AbstractAction() {
)
}
}
items(fetchedFriends ?: emptyList()) { userId ->
fun fetchLocalLinkType(): FriendLinkType? {
return context.database.getFriendInfo(userId)?.friendLinkType?.let { FriendLinkType.fromValue(it) }
items(filteredFriends) { userId ->
val friendInfo = remember(userId) { context.database.getFriendInfo(userId) }
val linkType = remember(friendInfo) {
friendInfo?.friendLinkType?.let { FriendLinkType.fromValue(it) }
}
fun isActuallyAdded(): Boolean {
val friendInfo = context.database.getFriendInfo(userId)
return friendInfo != null &&
(friendInfo.friendLinkType == FriendLinkType.MUTUAL.value ||
friendInfo.friendLinkType == FriendLinkType.OUTGOING.value) &&
friendInfo.addedTimestamp > 0L
val isActuallyAdded = remember(friendInfo, linkType) {
friendInfo != null && linkType != null && friendInfo.addedTimestamp > 0 &&
(linkType == FriendLinkType.MUTUAL || linkType == FriendLinkType.OUTGOING)
}
var friendSnapchatter by remember(userId) { mutableStateOf<Snapchatter?>(null) }
var failedToFetch by remember(userId) { mutableStateOf(false) }
var friendLinkType by remember(userId) { mutableStateOf(fetchLocalLinkType()) }
var actuallyAdded by remember(userId) { mutableStateOf(isActuallyAdded()) }
var friendLinkType by remember(userId) { mutableStateOf(linkType) }
var actuallyAdded by remember(userId) { mutableStateOf(isActuallyAdded) }
var bitmojiBitmap by remember(userId, friendInfo?.bitmojiAvatarId) {
mutableStateOf(friendInfo?.bitmojiAvatarId?.let { bitmojiCache[it] })
}
LaunchedEffect(userId) {
launch(Dispatchers.IO) {
friendSnapchatter = userIdToSnapchatter.getOrPut(userId) {
context.feature(Messaging::class).fetchSnapchatterInfos(listOf(userId)).firstOrNull() ?: run {
failedToFetch = true
return@launch
if (friendSnapchatter == null && !userIdToSnapchatter.containsKey(userId)) {
withContext(Dispatchers.IO) {
context.feature(Messaging::class).fetchSnapchatterInfos(listOf(userId)).firstOrNull()?.let {
userIdToSnapchatter[userId] = it
friendSnapchatter = it
}
}
} else {
friendSnapchatter = userIdToSnapchatter[userId]
}
// Polling loop to keep status in sync (like FriendList.kt)
while (true) {
delay(2000)
val newLinkType = fetchLocalLinkType()
val newLinkType = friendInfo?.friendLinkType?.let { FriendLinkType.fromValue(it) }
if (newLinkType != friendLinkType) {
friendLinkType = newLinkType
}
val newActuallyAdded = isActuallyAdded()
val newActuallyAdded = friendInfo != null && linkType != null && friendInfo.addedTimestamp > 0 &&
(linkType == FriendLinkType.MUTUAL || linkType == FriendLinkType.OUTGOING)
if (newActuallyAdded != actuallyAdded) {
actuallyAdded = newActuallyAdded
}
}
}
LaunchedEffect(userId, friendInfo?.bitmojiAvatarId, friendInfo?.bitmojiSelfieId) {
if (bitmojiBitmap != null || friendInfo?.bitmojiAvatarId == null || friendInfo?.bitmojiSelfieId == null) return@LaunchedEffect
withContext(Dispatchers.IO) {
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(
friendInfo.bitmojiSelfieId,
friendInfo.bitmojiAvatarId,
BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D
) ?: return@withContext
runCatching {
RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, length ->
val avatarId = friendInfo.bitmojiAvatarId ?: return@downloadMedia
bitmojiCache[avatarId] = BitmapFactory.decodeStream(inputStream).also {
bitmojiBitmap = it
}
}
}
}
}
Row(
modifier = Modifier
@@ -511,94 +644,84 @@ class ManageFriendList : AbstractAction() {
.background(Color.White.copy(alpha = 0.05f))
.border(1.dp, accentGradient, RoundedCornerShape(14.dp))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Image(
bitmap = remember(bitmojiBitmap) { bitmojiBitmap?.asImageBitmap() ?: noBitmojiBitmap },
contentDescription = null,
modifier = Modifier.size(35.dp)
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
BasicText(
friendSnapchatter?.let { snapchatter ->
snapchatter.displayName?.let { "$it (${snapchatter.username}) " } ?: snapchatter.username ?: context.translation["common.unknown"]
} ?: userId,
style = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Text(
text = friendSnapchatter?.let { snapchatter ->
snapchatter.displayName?.let { "$it (${snapchatter.username})" }
?: snapchatter.username
?: context.translation["common.unknown"]
} ?: context.translation["common.unknown"],
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
BasicText(
userId,
style = androidx.compose.ui.text.TextStyle(color = Color(0xFFB1B4D7), fontSize = 12.sp)
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
friendLinkType?.let { type ->
StatusPill(
text = type.name.lowercase().replaceFirstChar { it.uppercase() },
color = if (type == FriendLinkType.MUTUAL) Color(0xFF8EF0F3) else Color(0xFFD9D3FF)
)
}
if (failedToFetch) {
StatusPill(
text = translation.get("failed_to_fetch") ?: "Fetch failed",
color = Color(0xFFF4B4B4)
)
}
}
}
if (friendSnapchatter != null) {
val isPending = pendingFriendRequests.containsKey(userId) && pendingFriendRequests[userId]?.isActive != false
val isFollowing = friendLinkType == FriendLinkType.FOLLOWING
if (friendSnapchatter != null) {
val isPending = pendingFriendRequests.containsKey(userId) && pendingFriendRequests[userId]?.isActive != false
val isFollowing = friendLinkType == FriendLinkType.FOLLOWING
PrimaryButton(
text = when {
isFollowing -> "Following"
actuallyAdded -> context.translation["common.added"]
isPending -> translation.get("adding") ?: "Adding..."
else -> translation.get("add")
},
modifier = Modifier.widthIn(min = 110.dp),
enabled = !actuallyAdded && !isPending && !isFollowing
) {
if (actuallyAdded || isPending || isFollowing) return@PrimaryButton
val job = coroutineScope.launch {
try {
PrimaryButton(
text = when {
isFollowing -> "Following"
actuallyAdded -> context.translation["common.added"]
isPending -> translation.get("adding") ?: "Adding..."
else -> translation.get("add")
},
modifier = Modifier.widthIn(min = 110.dp),
enabled = !actuallyAdded && !isPending && !isFollowing
) {
if (actuallyAdded || isPending || isFollowing) return@PrimaryButton
val prevLinkType = friendLinkType
addFriend(userId)
delay(300)
actuallyAdded = true
withTimeout(3000) {
var attempts = 0
while (attempts < 12) {
val currentLinkType = fetchLocalLinkType()
if (currentLinkType == FriendLinkType.MUTUAL ||
currentLinkType == FriendLinkType.FOLLOWING ||
currentLinkType == FriendLinkType.OUTGOING) {
friendLinkType = currentLinkType
actuallyAdded = true
break
val job = coroutineScope.launch {
withTimeout(10000) {
while (friendInfo?.friendLinkType?.let { FriendLinkType.fromValue(it) }?.value == prevLinkType?.value) {
delay(500)
}
attempts++
delay(250)
}
}.apply {
invokeOnCompletion {
pendingFriendRequests.remove(userId)
friendLinkType = friendInfo?.friendLinkType?.let { FriendLinkType.fromValue(it) }
actuallyAdded = isActuallyAdded || (friendInfo != null && linkType != null && friendInfo.addedTimestamp > 0 &&
(linkType == FriendLinkType.MUTUAL || linkType == FriendLinkType.OUTGOING))
}
}
} catch (e: Exception) {
context.log.error("Failed to add friend or verify status: ${e.message}")
actuallyAdded = true
pendingFriendRequests[userId] = job
}
}.apply {
invokeOnCompletion {
pendingFriendRequests.remove(userId)
friendLinkType = fetchLocalLinkType()
actuallyAdded = isActuallyAdded() || actuallyAdded
if (isPending) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color(0xFF8EF0F3)
)
}
}
pendingFriendRequests[userId] = job
}
if (isPending) {
Spacer(modifier = Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color(0xFF8EF0F3)
)
}
}
}

View File

@@ -84,14 +84,14 @@ class BridgeClient(
//ensure the remote process is running
runCatching {
startActivity(Intent()
.setClassName(Constants.SE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.ForceStartActivity")
.setClassName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.ForceStartActivity")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
)
}
runCatching {
val intent = Intent()
.setClassName(Constants.SE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BridgeService")
.setClassName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BridgeService")
runCatching {
if (this@BridgeClient::service.isInitialized) {
unbindService(this@BridgeClient)
@@ -285,3 +285,4 @@ class BridgeClient(
fun getDebugProp(name: String, defaultValue: String? = null): String? = safeServiceCall { service.getDebugProp(name, defaultValue) }
}

View File

@@ -1,5 +1,7 @@
package me.eternal.purrfectsnap.core.data
import me.eternal.purrfectsnap.core.util.ClassDetector
class SnapClassCache (
private val classLoader: ClassLoader
) {
@@ -19,10 +21,50 @@ class SnapClassCache (
val conversation by lazy { findClass("com.snapchat.client.messaging.Conversation") }
val feedManager by lazy { findClass("com.snapchat.client.messaging.FeedManager\$CppProxy") }
val nativeBridge by lazy { runCatching { findClass("com.snapchat.client.valdi.NativeBridge") }.getOrNull() ?: findClass("com.snapchat.client.composer.NativeBridge") }
val composerView by lazy { runCatching { findClass("com.snap.composer.views.ComposerView") }.getOrNull() }
val composerAction by lazy { runCatching { findClass("com.snap.composer.actions.ComposerAction") }.getOrNull() }
val composerFunctionActionAdapter by lazy { runCatching { findClass("com.snap.composer.callable.ComposerFunctionActionAdapter") }.getOrNull() }
val valdiView by lazy { runCatching { findClass("com.snap.valdi.views.ValdiView") }.getOrNull() ?: runCatching { findClass("com.snap.composer.views.ComposerView") }.getOrNull() }
val valdiFunction by lazy {
ClassDetector.findClassBySignature(
classLoader = classLoader,
knownNames = listOf(
"com.snap.valdi.callable.ValdiFunction",
"com.snap.composer.callable.ComposerFunction"
),
methodSignature = { clazz ->
clazz.isInterface && clazz.methods.any {
it.name == "perform" && it.parameterTypes.size == 1 &&
it.returnType == Boolean::class.javaPrimitiveType
}
}
)
}
val valdiMarshaller by lazy {
ClassDetector.findClassBySignature(
classLoader = classLoader,
knownNames = listOf(
"com.snap.valdi.utils.ValdiMarshaller",
"com.snap.composer.utils.ComposerMarshaller"
),
methodSignature = { clazz ->
!clazz.isInterface && clazz.methods.any { it.name == "getUntyped" }
}
)
}
val valdiFunctionActionAdapter by lazy {
ClassDetector.findClassBySignature(
classLoader = classLoader,
knownNames = listOf(
"com.snap.valdi.callable.ValdiFunctionActionAdapter",
"com.snap.composer.callable.ComposerFunctionActionAdapter"
),
methodSignature = { clazz ->
!clazz.isInterface && clazz.interfaces.isNotEmpty() &&
clazz.methods.any { it.name == "perform" && it.parameterTypes.size == 1 }
}
)
}
private fun findClass(className: String): Class<*> {
return try {
classLoader.loadClass(className)

View File

@@ -109,6 +109,7 @@ class FeatureManager(
HideStreakRestore(),
HideFriendFeedEntry(),
RequerySqlite(),
RefreshFriendSuggestions(),
CallButtonsOverride(),
SnapPreview(),
BypassScreenshotDetection(),
@@ -135,7 +136,7 @@ class FeatureManager(
HideActiveMusic(),
AutoOpenSnaps(),
CustomStreaksExpirationFormat(),
ComposerHooks(),
ValdiHooks(),
DisableCustomTabs(),
BestFriendPinning(),
ContextMenuFix(),

View File

@@ -82,9 +82,6 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D)
val downloadLogging by context.config.downloader.logging
if (downloadLogging.contains("started")) {
context.shortToast(translations["download_started_toast"])
}
val outputPath = createNewFilePath(
context.config,

View File

@@ -1,42 +1,13 @@
package me.eternal.purrfectsnap.core.features.impl.downloader
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.Button
import android.widget.RelativeLayout
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.AccountCircle
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
@SuppressLint("SetTextI18n")
@@ -51,153 +22,30 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
context.event.subscribe(AddViewEvent::class) { event ->
if (event.view::class.java.name != "com.snap.unifiedpublicprofile.UnifiedPublicProfileView") return@subscribe
event.parent.addView(ImageButton(event.parent.context).apply {
val label = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
val density = resources.displayMetrics.density
val sizePx = (44f * density).toInt()
contentDescription = label
setImageResource(android.R.drawable.stat_sys_download)
setColorFilter(Color.WHITE)
scaleType = ImageView.ScaleType.CENTER_INSIDE
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor("#332A2452"))
setStroke(3, Color.parseColor("#66AFA3FF"))
}
setPadding(0, 0, 0, 0)
layoutParams = RelativeLayout.LayoutParams(
sizePx,
sizePx
).apply {
event.parent.addView(Button(event.parent.context).apply {
text = this@ProfilePictureDownloader.context.translation["profile_picture_downloader.button"]
layoutParams = RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply {
setMargins(0, 200, 0, 0)
}
setOnClickListener {
val activity = this@ProfilePictureDownloader.context.mainActivity ?: return@setOnClickListener
val translation = this@ProfilePictureDownloader.context.translation
val options = buildList {
backgroundUrl?.let { add("background_option" to it) }
avatarUrl?.let { add("avatar_option" to it) }
}
createComposeAlertDialog(activity) { alertDialog ->
PurrfectOverlayTheme {
val dialogTitle = translation["profile_picture_downloader.title"]
?: "Profile Picture Downloader"
val subtitle = friendUsername ?: translation["profile_picture_downloader.subtitle"]
?: "Choose which image to download"
val border = remember {
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f)
)
ViewAppearanceHelper.newAlertDialogBuilder(
this@ProfilePictureDownloader.context.mainActivity!!
).apply {
setTitle(this@ProfilePictureDownloader.context.translation["profile_picture_downloader.title"])
val choices = mutableMapOf<String, String>()
backgroundUrl?.let { choices["background_option"] = it }
avatarUrl?.let { choices["avatar_option"] = it }
setItems(choices.keys.map {
this@ProfilePictureDownloader.context.translation["profile_picture_downloader.$it"]
}.toTypedArray()) { _, which ->
runCatching {
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
choices.values.elementAt(which),
friendUsername!!
)
}
val shape = RoundedCornerShape(26.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
shape = shape,
color = ComposeColor.Transparent,
tonalElevation = 0.dp,
shadowElevation = 18.dp,
border = BorderStroke(1.dp, border)
) {
Column(
modifier = Modifier
.background(PurrfectOverlayPalette.cardOverlay, shape)
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = RoundedCornerShape(14.dp),
color = ComposeColor.White.copy(alpha = 0.08f)
) {
Icon(
Icons.Outlined.Download,
contentDescription = null,
tint = ComposeColor.White,
modifier = Modifier.padding(8.dp)
)
}
Column {
Text(
text = dialogTitle,
color = ComposeColor.White,
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold
)
Text(
text = subtitle,
color = PurrfectOverlayPalette.textSecondary,
fontSize = 12.sp
)
}
}
if (options.isEmpty()) {
Text(
text = translation["profile_picture_downloader.no_images"]
?: "No profile images found.",
color = ComposeColor.White.copy(alpha = 0.8f),
fontSize = 13.sp
)
} else {
options.forEach { (key, url) ->
val labelText = translation["profile_picture_downloader.$key"]
val icon = if (key == "background_option") {
Icons.Outlined.Image
} else {
Icons.Outlined.AccountCircle
}
Surface(
onClick = {
runCatching {
this@ProfilePictureDownloader.context.feature(MediaDownloader::class).downloadProfilePicture(
url,
friendUsername!!
)
}.onFailure {
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
}
alertDialog.dismiss()
},
shape = RoundedCornerShape(16.dp),
color = ComposeColor.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, ComposeColor.White.copy(alpha = 0.12f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = ComposeColor.White.copy(alpha = 0.08f)
) {
Icon(
icon,
contentDescription = null,
tint = ComposeColor.White,
modifier = Modifier.padding(8.dp)
)
}
Text(
text = labelText ?: key,
color = ComposeColor.White,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
}
}
}
}
}
}.onFailure {
this@ProfilePictureDownloader.context.log.error("Failed to download profile picture", it)
}
}
}.show()
@@ -220,4 +68,4 @@ class ProfilePictureDownloader : Feature("ProfilePictureDownloader") {
}
}
}
}
}

View File

@@ -56,7 +56,7 @@ class AppLock : Feature("AppLock") {
private fun requestUnlock() {
isUnlockRequested = true
context.mainActivity!!.startActivityForResult(Intent().apply {
component = ComponentName(Constants.SE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BiometricPromptActivity")
component = ComponentName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.bridge.BiometricPromptActivity")
}, requestCode)
}
@@ -151,4 +151,4 @@ class AppLock : Feature("AppLock") {
lock()
}
}
}
}

View File

@@ -21,7 +21,7 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
private fun generateAndroidId(): String {
if (spoofedAndroidId != null) return spoofedAndroidId!!
val customId = context.config.experimental.spoof.customAndroidId.getNullable()
val customId = context.config.experimental.spoof.spoofDeviceId.customAndroidId.getNullable()
if (!customId.isNullOrEmpty()) {
spoofedAndroidId = customId.lowercase()
if (!hasLoggedId) {
@@ -242,7 +242,7 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
val overridePlayStoreInstallerPackageName by context.config.experimental.spoof.overridePlayStoreInstallerPackageName
val removeVpnTransportFlag by context.config.experimental.spoof.removeVpnTransportFlag
val forceWifiTransportFlag by context.config.experimental.spoof.forceWifiTransportFlag
val spoofAndroidId by context.config.experimental.spoof.spoofAndroidId
val spoofAndroidId by context.config.experimental.spoof.spoofDeviceId.spoofAndroidId
if(overridePlayStoreInstallerPackageName) {
hookInstallerPackageName()

View File

@@ -188,7 +188,7 @@ class MediaFilePicker : Feature("Media File Picker") {
val isAudio = context.androidContext.contentResolver.getType(event.intent.data!!)!!.startsWith("audio/")
if (isAudio || context.config.messaging.galleryMediaSendOverride.getNullable() == null) {
if (isAudio || context.config.messaging.galleryMediaSendOverride.mode.getNullable() == null) {
startConversion(isAudio)
return@subscribe
}

View File

@@ -5,8 +5,8 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.getComposerContext
import me.eternal.purrfectsnap.core.ui.getComposerViewNode
import me.eternal.purrfectsnap.core.ui.getValdiContext
import me.eternal.purrfectsnap.core.ui.getValdiViewNode
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
@@ -35,23 +35,23 @@ class SnapScoreChanges: Feature("Snap Score Changes") {
context.event.subscribe(AddViewEvent::class) { event ->
if (event.viewClassName.endsWith("UnifiedProfileFlatlandProfileViewTopViewFrameLayout")) {
val composerView = (event.view as ViewGroup).getChildAt(0) ?: return@subscribe
val composerContext = composerView.getComposerContext() ?: return@subscribe
val composerContext = composerView.getValdiContext() ?: return@subscribe
lastViewedUserId = composerContext.viewModel?.getObjectField("_userId")?.toString()
}
if (event.viewClassName.endsWith("ProfileFlatlandFriendSnapScoreIdentityPillDialogView")) {
event.view.post {
event.view.getComposerContext()!!.enqueueNextRenderCallback {
val composerViewNode = event.view.getComposerViewNode() ?: return@enqueueNextRenderCallback
event.view.getValdiContext()!!.enqueueNextRenderCallback {
val composerViewNode = event.view.getValdiViewNode() ?: return@enqueueNextRenderCallback
val surface = composerViewNode.getChildren().getOrNull(1) ?: return@enqueueNextRenderCallback
val snapTextView = surface.getChildren().lastOrNull {
it.getClassName() == "com.snap.composer.views.ComposerSnapTextView"
it.getClassName().endsWith("SnapTextView")
} ?: return@enqueueNextRenderCallback
val currentFriendScore = scores[lastViewedUserId] ?: (event.view.getComposerContext()?.viewModel?.getObjectField("_friendSnapScore") as? Double)?.toLong() ?: return@enqueueNextRenderCallback
val currentFriendScore = scores[lastViewedUserId] ?: (event.view.getValdiContext()?.viewModel?.getObjectField("_friendSnapScore") as? Double)?.toLong() ?: return@enqueueNextRenderCallback
val oldSnapScore = context.bridgeClient.getTracker().updateFriendScore(
lastViewedUserId ?: return@enqueueNextRenderCallback,
@@ -68,4 +68,4 @@ class SnapScoreChanges: Feature("Snap Score Changes") {
}
}
}
}
}

View File

@@ -0,0 +1,238 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material3.*
import androidx.compose.runtime.getValue
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.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
import me.eternal.purrfectsnap.common.bridge.toWrapper
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.PurrfectSnap
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiFunction
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiMarshaller
import me.eternal.purrfectsnap.nativelib.NativeLib
import kotlin.math.absoluteValue
import kotlin.random.Random
class ValdiHooks: Feature("ValdiHooks") {
private val config by lazy { context.config.experimental.nativeHooks.valdiHooks }
private val getImportsFunctionName = Random.nextLong().absoluteValue.toString(16)
private var evalFunction: ValdiFunction? = null
private val valdiConsole by lazy {
createComposeAlertDialog(context.mainActivity!!) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
var result by remember { mutableStateOf("") }
var codeContent by remember { mutableStateOf("1 + 2") }
Text("Valdi Console", fontSize = 18.sp, fontWeight = FontWeight.Bold)
TextField(
modifier = Modifier.fillMaxWidth(),
textStyle = TextStyle.Default.copy(fontSize = 12.sp),
value = codeContent,
placeholder = { Text("Enter your JS code here:") },
onValueChange = {
codeContent = it
}
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
context.log.verbose("input: $codeContent", "ValdiConsole")
result = "Running..."
context.coroutineScope.launch {
ValdiMarshaller.create()?.use { valdiMarshaller ->
valdiMarshaller.pushUntyped(codeContent)
valdiMarshaller.pushUntyped(newValdiFunction {
if (getSize() < 1) return@newValdiFunction false
val output = getUntyped(0)
context.log.verbose("eval: $output", "ValdiConsole")
result = if (output is Exception) {
"${output.javaClass.simpleName}: ${output.message}"
} else {
output?.toString() ?: "undefined"
}
true
})
evalFunction?.perform(valdiMarshaller)
} ?: run {
result = "Failed to create ValdiMarshaller"
}
}
}
) {
Text("Run")
}
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Text(result)
}
}
}
}
private fun newValdiFunction(block: ValdiMarshaller.() -> Boolean): Any? {
val functionClass = PurrfectSnap.classCache.valdiFunction ?: return null
return java.lang.reflect.Proxy.newProxyInstance(
functionClass.classLoader,
arrayOf(functionClass)
) { _, method, args ->
if (method.name != "perform") return@newProxyInstance null
block(ValdiMarshaller(args?.get(0) ?: return@newProxyInstance false))
}
}
@Suppress("UNCHECKED_CAST")
override fun init() {
if (config.globalState != true) return
if (PurrfectSnap.classCache.valdiFunction == null) {
context.log.warn("ComposerFunction/ValdiFunction class not found, ValdiHooks feature disabled")
return
}
val importedFunctions = mutableMapOf<String, Any?>()
fun valdiFunction(name: String, block: ValdiMarshaller.() -> Unit) {
val function = newValdiFunction {
block(this)
true
}
if (function != null) {
importedFunctions[name] = function
}
}
valdiFunction("getConfig") {
pushUntyped(mapOf<String, Any>(
"operaDownloadButton" to context.config.downloader.operaDownloadButton.get(),
"bypassCameraRollLimit" to config.bypassCameraRollLimit.get(),
"showFirstCreatedUsername" to config.showFirstCreatedUsername.get(),
"valdiLogs" to config.valdiLogs.get(),
"customSelfDestructSnapDelay" to config.customSelfDestructSnapDelay.get(),
))
}
valdiFunction("showToast") {
if (getSize() < 1) return@valdiFunction
context.shortToast(getUntyped(0) as? String ?: return@valdiFunction)
}
valdiFunction("downloadLastOperaMedia") {
context.feature(MediaDownloader::class).downloadLastOperaMediaAsync(getUntyped(0) == true)
}
valdiFunction("getFriendOriginalUsername") {
if (getSize() < 1) return@valdiFunction
val username = getUntyped(0) as? String ?: return@valdiFunction
runCatching {
pushUntyped(context.database.getFriendOriginalUsername(username))
}.onFailure {
pushUntyped(null)
}
}
valdiFunction("log") {
if (getSize() < 2) return@valdiFunction
val logLevel = getUntyped(0) as? String ?: return@valdiFunction
val message = getUntyped(1) as? String ?: return@valdiFunction
val tag = "ValdiLogs"
when (logLevel) {
"log" -> context.log.verbose(message, tag)
"debug" -> context.log.debug(message, tag)
"info" -> context.log.info(message, tag)
"warn" -> context.log.warn(message, tag)
"error" -> context.log.error(message, tag)
}
}
valdiFunction("setEvalFunction") {
if (getSize() < 1) return@valdiFunction
evalFunction = ValdiFunction(getUntyped(0) ?: return@valdiFunction)
context.log.verbose("Set eval function: $evalFunction", "ValdiHooks")
}
fun loadHooks() {
if (!NativeLib.initialized) {
context.log.error("ValdiHooks cannot be loaded without NativeLib")
return
}
val loaderScript = runCatching {
context.fileHandlerManager.getFileHandle(FileHandleScope.VALDI.key, "loader.js").toWrapper().readBytes().toString(Charsets.UTF_8)
}.onFailure {
context.log.error("Failed to load valdi loader script", it)
}.getOrNull() ?: return
context.native.setValdiLoader("""
const i = setInterval(() => {
try {
const _runtimeName = "${if (PurrfectSnap.classCache.nativeBridge.name == "com.snapchat.client.valdi.NativeBridge") "valdi" else "composer"}";
require(_runtimeName + '_core/src/DeviceBridge').getDisplayWidth();
clearInterval(i);
(() => { const _getImportsFunctionName = "$getImportsFunctionName"; $loaderScript })();
} catch (e) {}
}, 200)
""".trimIndent().trim())
}
loadHooks()
if (config.valdiConsole.get()) {
context.inAppOverlay.addCustomComposable {
FilledIconButton(
onClick = {
valdiConsole.show()
},
modifier = Modifier.align(Alignment.TopEnd).padding(top = 100.dp, end = 16.dp)
) {
Icon(Icons.Default.BugReport, contentDescription = "Debug Console")
}
}
}
PurrfectSnap.classCache.nativeBridge.hook("registerNativeModuleFactory", HookStage.BEFORE) { param ->
val moduleFactory = param.argNullable<Any>(1) ?: return@hook
if (moduleFactory.javaClass.getMethod("getModulePath").invoke(moduleFactory)?.toString()?.contains("DeviceBridge") != true) return@hook
Hooker.ephemeralHookObjectMethod(moduleFactory.javaClass, moduleFactory, "loadModule", HookStage.AFTER) { methodParam ->
val result = methodParam.getResult() as? MutableMap<String, Any?> ?: return@ephemeralHookObjectMethod
val importsFunction = newValdiFunction {
pushUntyped(importedFunctions)
true
}
if (importsFunction != null) {
result[getImportsFunctionName] = importsFunction
}
}
}
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.ui.CustomComposable
import me.eternal.purrfectsnap.core.util.dataBuilder
@@ -105,6 +106,19 @@ class EndpointsBlocker : Feature("EndpointsBlocker") {
context.native.setTestMode(false)
}
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
val bypassToggleEnabled = context.bridgeClient.getDebugProp("test_mode", "false") == "true"
if (!bypassToggleEnabled && context.disablePlugin) {
return@subscribe
}
if (isInLoginSignup) return@subscribe
val decision = context.native.evaluateNetworkRequest(event.url)
if (decision.blocked) {
event.canceled = true
}
}
context.event.subscribe(UnaryCallEvent::class) { event ->
val bypassToggleEnabled = context.bridgeClient.getDebugProp("test_mode", "false") == "true"
if (!bypassToggleEnabled && context.disablePlugin) {
@@ -117,7 +131,6 @@ class EndpointsBlocker : Feature("EndpointsBlocker") {
val arg0 = event.adapter.arg<Any>(0).toString()
val decision = context.native.evaluateEndpoint(event.uri, arg0, hasAttestation)
if (decision.blocked) {
event.canceled = true
val eventHandler = event.adapter.arg<Any>(3)
@@ -139,7 +152,8 @@ class EndpointsBlocker : Feature("EndpointsBlocker") {
if (isInLoginSignup) return@hook
val path = param.arg<String>(0)
if (context.native.shouldBlockDuplexClient(path)) {
val blocked = context.native.shouldBlockDuplexClient(path)
if (blocked) {
param.setResult(null)
return@hook
}
@@ -158,7 +172,6 @@ class EndpointsBlocker : Feature("EndpointsBlocker") {
val requestPath = authContextRequest.getObjectField("mRequestPath").toString()
val attestationRequired = authContextRequest.getObjectField("mAttestationRequired") == true
val decision = context.native.evaluateAuthContext(requestPath, attestationRequired)
if (decision.blocked) {
param.setResult(null)
}

View File

@@ -486,6 +486,7 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
val provider = ai.aiProvider.get()
val apiKey = ai.aiApiKey.get().trim()
val model = when (provider) {
"openrouter" -> ai.aiModel.get().ifBlank { "deepseek/deepseek-r1-0528:free" }
"deepseek" -> ai.aiModel.get().ifBlank { "deepseek-chat" }
"openai" -> ai.aiModel.get().ifBlank { "gpt-4o-mini" }
else -> ai.aiModel.get().ifBlank { "gemini-2.5-flash" }
@@ -515,7 +516,8 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
suspend fun makeRequest(): String = withTimeout(timeoutSec * 1000L) {
withContext(Dispatchers.IO) {
when (provider) {
"deepseek", "openai" -> {
"openrouter", "openai", "deepseek" -> {
val payload = JsonObject().apply {
addProperty("model", model)
add("messages", JsonArray().apply {
@@ -529,51 +531,46 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
if (maxTokens > 0) addProperty("max_tokens", maxTokens)
addProperty("temperature", temp)
}
val body = payload.toString()
val apiUrl = if (provider == "openai") "https://api.openai.com/v1/chat/completions" else "https://api.deepseek.com/v1/chat/completions"
val req = Request.Builder()
val apiUrl = when (provider) {
"openrouter" -> "https://openrouter.ai/api/v1/chat/completions"
"openai" -> "https://api.openai.com/v1/chat/completions"
else -> "https://api.deepseek.com/v1/chat/completions"
}
val reqBuilder = Request.Builder()
.url(apiUrl)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
if (provider == "openrouter") {
reqBuilder
.addHeader("HTTP-Referer", "https://purrfectsnap.app")
.addHeader("X-Title", "PurrfectSnap")
}
val request = reqBuilder
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(req).execute().use { resp ->
val respBody = resp.body?.string().orEmpty()
if (!resp.isSuccessful) throw Throwable(if (resp.code == 401) "$provider HTTP 401 (check API key)" else "$provider HTTP ${resp.code}: ${respBody.take(200)}")
val json = JsonParser.parseString(respBody).asJsonObject
json.getAsJsonArray("choices")?.firstOrNull()?.asJsonObject
?.getAsJsonObject("message")?.get("content")?.asString?.trim().orEmpty()
}
}
else -> {
val systemPrompt = messages.firstOrNull { it.role == "system" }?.content ?: ""
val userMessages = messages.filter { it.role == "user" }.joinToString("\n") { it.content }
val combinedText = "$systemPrompt\n$userMessages"
val payload = JsonObject().apply {
val parts = JsonArray().apply {
add(JsonObject().apply { addProperty("text", combinedText) })
httpClient.newCall(request).execute().use { resp ->
val body = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
throw Throwable("AI error ${resp.code}: ${body.take(200)}")
}
val contents = JsonArray().apply {
add(JsonObject().apply { add("parts", parts) })
}
add("contents", contents)
}
val body = payload.toString()
val req = Request.Builder()
.url("https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent")
.addHeader("x-goog-api-key", apiKey)
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(req).execute().use { resp ->
val respBody = resp.body?.string().orEmpty()
if (!resp.isSuccessful) throw Throwable(if (resp.code == 401) "Gemini HTTP 401 (check API key)" else "Gemini HTTP ${resp.code}: ${respBody.take(200)}")
val json = JsonParser.parseString(respBody).asJsonObject
val candidates = json.getAsJsonArray("candidates")
val content = candidates?.firstOrNull()?.asJsonObject?.getAsJsonObject("content")
content?.getAsJsonArray("parts")?.firstOrNull()?.asJsonObject?.get("text")?.asString?.trim().orEmpty()
JsonParser.parseString(body)
.asJsonObject
.getAsJsonArray("choices")
?.firstOrNull()?.asJsonObject
?.getAsJsonObject("message")
?.get("content")
?.asString
?.trim()
.orEmpty()
}
}
else -> throw IllegalArgumentException("Unsupported AI provider: $provider")
}
}
}
@@ -650,6 +647,7 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
val provider = ai.aiProvider.get()
val apiKey = ai.aiApiKey.get().trim()
val model = when (provider) {
"openrouter" -> ai.aiModel.get().ifBlank { "deepseek/deepseek-r1-0528:free" }
"deepseek" -> ai.aiModel.get().ifBlank { "deepseek-chat" }
"openai" -> ai.aiModel.get().ifBlank { "gpt-4o-mini" }
else -> ai.aiModel.get().ifBlank { "gemini-2.5-flash" }
@@ -692,7 +690,8 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
suspend fun makeRequest(): String = withTimeout(timeoutSec * 1000L) {
withContext(Dispatchers.IO) {
when (provider) {
"deepseek", "openai" -> {
"openrouter", "openai", "deepseek" -> {
val payload = JsonObject().apply {
addProperty("model", model)
add("messages", JsonArray().apply {
@@ -706,51 +705,46 @@ class AutoReply : MessagingRuleFeature("Auto Reply", MessagingRuleType.AUTO_REPL
if (maxTokens > 0) addProperty("max_tokens", maxTokens)
addProperty("temperature", temp)
}
val body = payload.toString()
val apiUrl = if (provider == "openai") "https://api.openai.com/v1/chat/completions" else "https://api.deepseek.com/v1/chat/completions"
val req = Request.Builder()
val apiUrl = when (provider) {
"openrouter" -> "https://openrouter.ai/api/v1/chat/completions"
"openai" -> "https://api.openai.com/v1/chat/completions"
else -> "https://api.deepseek.com/v1/chat/completions"
}
val reqBuilder = Request.Builder()
.url(apiUrl)
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
if (provider == "openrouter") {
reqBuilder
.addHeader("HTTP-Referer", "https://purrfectsnap.app")
.addHeader("X-Title", "PurrfectSnap")
}
val request = reqBuilder
.post(payload.toString().toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(req).execute().use { resp ->
val respBody = resp.body?.string().orEmpty()
if (!resp.isSuccessful) throw Throwable(if (resp.code == 401) "$provider HTTP 401 (check API key)" else "$provider HTTP ${resp.code}: ${respBody.take(200)}")
val json = JsonParser.parseString(respBody).asJsonObject
json.getAsJsonArray("choices")?.firstOrNull()?.asJsonObject
?.getAsJsonObject("message")?.get("content")?.asString?.trim().orEmpty()
}
}
else -> {
val systemPromptText = messages.firstOrNull { it.role == "system" }?.content ?: ""
val userMessages = messages.filter { it.role == "user" }.joinToString("\n") { it.content }
val combinedText = "$systemPromptText\n$userMessages"
val payload = JsonObject().apply {
val parts = JsonArray().apply {
add(JsonObject().apply { addProperty("text", combinedText) })
httpClient.newCall(request).execute().use { resp ->
val body = resp.body?.string().orEmpty()
if (!resp.isSuccessful) {
throw Throwable("AI error ${resp.code}: ${body.take(200)}")
}
val contents = JsonArray().apply {
add(JsonObject().apply { add("parts", parts) })
}
add("contents", contents)
}
val body = payload.toString()
val req = Request.Builder()
.url("https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent")
.addHeader("x-goog-api-key", apiKey)
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
httpClient.newCall(req).execute().use { resp ->
val respBody = resp.body?.string().orEmpty()
if (!resp.isSuccessful) throw Throwable(if (resp.code == 401) "Gemini HTTP 401 (check API key)" else "Gemini HTTP ${resp.code}: ${respBody.take(200)}")
val json = JsonParser.parseString(respBody).asJsonObject
val candidates = json.getAsJsonArray("candidates")
val content = candidates?.firstOrNull()?.asJsonObject?.getAsJsonObject("content")
content?.getAsJsonArray("parts")?.firstOrNull()?.asJsonObject?.get("text")?.asString?.trim().orEmpty()
JsonParser.parseString(body)
.asJsonObject
.getAsJsonArray("choices")
?.firstOrNull()?.asJsonObject
?.getAsJsonObject("message")
?.get("content")
?.asString
?.trim()
.orEmpty()
}
}
else -> throw IllegalArgumentException("Unsupported AI provider: $provider")
}
}
}

View File

@@ -58,7 +58,9 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
onNextActivityCreate {
if (callStartConfirmation) {
findClass("com.snap.composer.views.ComposerRootView").hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
(runCatching { findClass("com.snap.valdi.views.ValdiRootView") }.getOrNull()
?: findClass("com.snap.composer.views.ComposerRootView"))
.hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
val view = param.thisObject() as? ViewGroup ?: return@hook
if (!view.javaClass.name.endsWith("CallButtonsView")) return@hook
val childComposerView = view.getChildAt(0) as? ViewGroup ?: return@hook
@@ -87,4 +89,4 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
}
}
}
}
}

View File

@@ -60,7 +60,7 @@ class Messaging : Feature("Messaging") {
context.messagingBridge.triggerSessionStart()
context.mainActivity?.takeIf { it.intent.getBooleanExtra(ReceiversConfig.MESSAGING_PREVIEW_EXTRA, false) }?.run {
startActivity(Intent().apply {
setComponent(ComponentName(Constants.SE_PACKAGE_NAME, "me.eternal.purrfectsnap.ui.manager.MainActivity"))
setComponent(ComponentName(Constants.MODULE_PACKAGE_NAME, "me.eternal.purrfectsnap.ui.manager.MainActivity"))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
@@ -215,3 +215,4 @@ class Messaging : Feature("Messaging") {
return (future.get() as? List<*>)?.map { Snapchatter(it) } ?: return emptyList()
}
}

View File

@@ -481,17 +481,18 @@ class Notifications : Feature("Notifications") {
if (!config.chatPreview.get() && config.mediaPreview.isEmpty()) return@hook
if (notificationType.endsWith("typing")) return@hook
val serverMessageId = extras.getString("message_id") ?: return@hook
val serverMessageId = extras.getString("message_id")?.trim().takeIf { !it.isNullOrEmpty() } ?: return@hook
val conversationId = extras.getString("conversation_id").also { id ->
sentNotifications.computeIfAbsent(notificationData.id) { id ?: "" }
} ?: return@hook
val serverMessageIdLong = serverMessageId.toLongOrNull() ?: return@hook
param.setResult(null)
val conversationManager = context.feature(Messaging::class).conversationManager ?: return@hook
context.coroutineScope.launch(coroutineDispatcher) {
suspendCoroutine { continuation ->
conversationManager.fetchMessageByServerId(conversationId, serverMessageId.toLong(), onSuccess = {
conversationManager.fetchMessageByServerId(conversationId, serverMessageIdLong, onSuccess = {
continuation.resumeWith(Result.success(Unit))
if (it.senderId.toString() == context.database.myUserId) {
param.invokeOriginal()

View File

@@ -3,8 +3,11 @@ 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.*
@@ -13,6 +16,8 @@ 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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@@ -20,6 +25,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedHelpers
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.bridge.task.TaskListener
import me.eternal.purrfectsnap.common.data.ContentType
@@ -32,7 +38,10 @@ 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
@@ -145,7 +154,8 @@ class SendOverride : Feature("Send Override") {
val stripMediaMetadata = context.config.messaging.stripMediaMetadata.get()
var postSavePolicy: Int? = null
val configOverrideType = context.config.messaging.galleryMediaSendOverride.getNullable()
val configOverrideType = context.config.messaging.galleryMediaSendOverride.mode.getNullable()
val includeCameraSnaps = context.config.messaging.galleryMediaSendOverride.includeCameraSnaps.get()
if (configOverrideType == null && stripMediaMetadata.isEmpty()) return
context.event.subscribe(MediaUploadEvent::class) { event ->
@@ -257,6 +267,9 @@ 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)
@@ -291,7 +304,8 @@ class SendOverride : Feature("Send Override") {
when (overrideType) {
"SNAP", "SAVEABLE_SNAP" -> {
postSavePolicy = if (overrideType == "SAVEABLE_SNAP") 3 /* VIEW_SESSION */ else 1 /* PROHIBITED */
val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 0 else 1
postSavePolicy = savePolicyValue
val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer()
@@ -322,7 +336,6 @@ 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)
// set snap duration
if (snapDurationMs != null) {
addVarInt(8, snapDurationMs / 1000)
if (snapDurationMs / 1000 <= 0) {
@@ -333,12 +346,46 @@ class SendOverride : Feature("Send Override") {
}
}
// set app source
edit(11, 22) {
remove(4)
addVarInt(4, 5) // APP_SOURCE_CAMERA
addVarInt(4, 5)
}
edit(11, 5) {
if (getOrNull(7) != null) {
remove(7)
}
addVarInt(7, savePolicyValue)
}
}.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" -> {
localMessageContent.contentType = ContentType.NOTE
@@ -353,8 +400,9 @@ class SendOverride : Feature("Send Override") {
return true
}
if (configOverrideType != "always_ask") {
if (sendMedia(configOverrideType, 10)) {
val overrideType = configOverrideType ?: return@subscribe
if (overrideType != "always_ask") {
if (sendMedia(overrideType, 10)) {
event.invokeOriginal()
}
return@subscribe
@@ -363,140 +411,199 @@ class SendOverride : Feature("Send Override") {
context.runOnUiThread {
val recipientNameForTask = recipientName
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
val mainTranslation = remember {
context.translation.getCategory("send_override_dialog")
}
PurrfectOverlayTheme {
val mainTranslation = remember {
context.translation.getCategory("send_override_dialog")
}
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)
)
)
}
val dialogBackground = remember {
Brush.linearGradient(
listOf(
Color(0xFF2A2452),
Color(0xFF1A143A)
)
)
}
@Composable
fun ActionTile(
modifier: Modifier = Modifier,
selected: Boolean = false,
icon: ImageVector,
title: String,
onClick: () -> Unit
) {
Card(
modifier = modifier,
onClick = onClick,
elevation = if (selected) CardDefaults.elevatedCardElevation(disabledElevation = 3.dp) else CardDefaults.cardElevation(),
colors = if (selected) CardDefaults.elevatedCardColors() else CardDefaults.cardColors()
@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)
)
Spacer(Modifier.height(6.dp))
Text(
title,
modifier = Modifier.fillMaxWidth(),
fontSize = 12.sp,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
softWrap = true,
lineHeight = 14.sp,
textAlign = TextAlign.Center
)
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = dialogShape,
color = dialogSurfaceColor,
tonalElevation = 0.dp,
shadowElevation = 18.dp,
border = BorderStroke(1.dp, border)
) {
Column(
modifier = Modifier
.padding(16.dp)
.size(75.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
.background(dialogBackground, dialogShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
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)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val translation = remember {
context.translation.getCategory("features.options.gallery_media_send_override")
}
var scheduleEnabled by remember { mutableStateOf(false) }
Text(fontSize = 20.sp, fontWeight = FontWeight.Medium, text = "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"]) {
selectedType = "ORIGINAL"
}
ActionTile(selected = selectedType == "SNAP" || selectedType == "SAVEABLE_SNAP", icon = Icons.Filled.PhotoCamera, title = translation["SNAP"]) {
selectedType = "SNAP"
}
ActionTile(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"
val translation = remember {
context.translation.getCategory("features.options.gallery_media_send_override")
}
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().clickable {
toggleSaveable()
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
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 = selectedType == "SAVEABLE_SNAP",
checked = scheduleEnabled,
onCheckedChange = {
toggleSaveable()
scheduleEnabled = it
if (!it) scheduledTime = null
}
)
Text(text = mainTranslation["saveable_snap_hint"], lineHeight = 15.sp)
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"])
}
}
}
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"])
}
}
}
if (scheduleEnabled && showClockPicker) {
val datePickerState = rememberDatePickerState(
@@ -755,11 +862,11 @@ class SendOverride : Feature("Send Override") {
Text(if (scheduledTime != null) mainTranslation["schedule"] else context.translation["button.send"])
}
}
}
}
}
}.show()
}
}
}
}

View File

@@ -56,6 +56,10 @@ class MessageLogger : MessagingRuleFeature("MessageLogger", MessagingRuleType.ME
loggerInterface.deleteMessage(conversationId, uniqueMessageId)
}
fun isLoggedMessageDeleted(uniqueMessageId: Long): Boolean {
return deletedMessageCache.containsKey(uniqueMessageId)
}
fun getMessageObject(conversationId: String, clientMessageId: Long): JsonObject? {
val uniqueMessageId = makeUniqueIdentifier(conversationId, clientMessageId) ?: return null
if (deletedMessageCache.containsKey(uniqueMessageId)) {

View File

@@ -103,15 +103,17 @@ class CameraTweaks : Feature("Camera Tweaks") {
findClass("android.media.ImageReader\$SurfaceImage").hook("getPlanes", HookStage.AFTER) { param ->
val image = param.thisObject() as? Image ?: return@hook
val planes = param.getResult() as? Array<*> ?: return@hook
val output = ByteArrayOutputStream()
Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888).apply {
compress(Bitmap.CompressFormat.JPEG, 100, output)
recycle()
}
planes.filterNotNull().forEach { plane ->
plane.setObjectField("mBuffer", ByteBuffer.wrap(output.toByteArray()))
// keep buffer size identical to the original to avoid crashes during copyPixelsFromBuffer
val buffer = runCatching {
plane.javaClass.getMethod("getBuffer").invoke(plane) as? ByteBuffer
}.getOrNull() ?: return@forEach
val zeroes = ByteArray(buffer.capacity())
buffer.clear()
buffer.put(zeroes)
buffer.flip()
}
}
}
}
}
}

View File

@@ -0,0 +1,43 @@
package me.eternal.purrfectsnap.core.features.impl.tweaks
import me.eternal.purrfectsnap.core.event.events.impl.NetworkApiRequestEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
class RefreshFriendSuggestions : Feature("Refresh Friend Suggestions") {
override fun init() {
listOf("Y26", "y26").forEach { className ->
listOf("m34803a", "mo81d").forEach { methodName ->
runCatching {
findClass(className).hook(methodName, HookStage.AFTER) { param ->
(param.getResult() as? MutableMap<String, String>)?.apply {
put("_t", System.currentTimeMillis().toString())
put("limit", "100")
}
}
}.onFailure {}
}
}
listOf("Hl3", "HL3", "nh3").forEach { className ->
listOf("m13680e2", "m58660d3").forEach { methodName ->
runCatching {
findClass(className).hook(methodName, HookStage.BEFORE) { param ->
if (param.arg<Int>(1) == 10) {
param.setArg(1, 100)
}
}
}.onFailure {}
}
}
context.event.subscribe(NetworkApiRequestEvent::class) { event ->
if (event.url.contains("suggest_friend")) {
val separator = if (event.url.contains("?")) "&" else "?"
event.url += "${separator}_t=${System.currentTimeMillis()}&limit=100"
}
}
}
}

View File

@@ -15,6 +15,7 @@ class RequerySqlite : Feature("Requery Sqlite") {
findClass("io.requery.android.database.sqlite.SQLiteDatabase").hook("rawQueryWithFactory", HookStage.BEFORE) { param ->
var sqlRequest = param.argNullable<String>(1) ?: return@hook
val sqlUpper = sqlRequest.uppercase().trim()
fun patchRequest(condition: String) {
sqlRequest.lastIndexOf("WHERE").takeIf { it != -1 }?.let {
@@ -23,18 +24,33 @@ class RequerySqlite : Feature("Requery Sqlite") {
}
}
if (hideQuickAddSuggestions && sqlRequest.contains("SuggestedFriendPlacement")) {
patchRequest("0 = 1")
fun isSuggestionQuery() = sqlRequest.contains("SuggestedFriendPlacement") ||
sqlRequest.contains("TopSuggestedFriend") ||
sqlRequest.contains("TopSuggestedFriendV2") ||
sqlRequest.contains("SuggestedFriend")
if (hideQuickAddSuggestions && sqlUpper.startsWith("SELECT") && isSuggestionQuery()) {
val isDisplayQuery = sqlRequest.contains("FriendWithUsername") ||
sqlRequest.contains("FROM TopSuggestedFriend") ||
(sqlRequest.contains("UNION") && sqlRequest.contains("FROM SuggestedFriend"))
val isCountQuery = sqlUpper.contains("SELECT 0") || sqlUpper.contains("SELECT COUNT")
if (isDisplayQuery || isCountQuery) {
patchRequest("0 = 1")
}
}
if (hideSuggestedStories && sqlRequest.contains("DiscoverFeedFriendStoriesViewV2 AS DFStories")) {
patchRequest("DFStories.isFriendOfFriend = 0")
}
if (hideFriendFeedEntry && sqlRequest.startsWith("SELECT") && (sqlRequest.contains("FriendWithUsername")) && sqlRequest.contains("userId")) {
if (hideFriendFeedEntry && sqlUpper.startsWith("SELECT") && sqlRequest.contains("FriendWithUsername") && sqlRequest.contains("userId")) {
if (isSuggestionQuery()) return@hook
val ids = context.bridgeClient.getRuleIds(MessagingRuleType.HIDE_FRIEND_FEED).takeIf { it.isNotEmpty() } ?: return@hook
patchRequest(ids.joinToString(" AND ") { "${if (sqlRequest.contains("Friend.userId")) "Friend.userId" else "userId "} != '$it'" })
val userIdField = if (sqlRequest.contains("Friend.userId")) "Friend.userId" else "userId"
patchRequest(ids.joinToString(" AND ") { "$userIdField != '$it'" })
}
}
}
}
}

View File

@@ -36,9 +36,26 @@ class UnsaveableMessages : MessagingRuleFeature(
if (!shouldApply) return@subscribe
val messageContentBytes = localMessageContent.content ?: return@subscribe
val config = context.config.messaging.unsaveableMessages
val enabledFields = mutableListOf<Int>()
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 protoReader = ProtoReader(messageContentBytes)
val fieldPath = enabledFields.firstOrNull { fieldId ->
protoReader.followPath(fieldId) != null
} ?: return@subscribe
shouldModifySavePolicy = true
// Modify mSavePolicy directly using XposedHelpers
try {
val savePolicyEnumClass = runCatching {
XposedHelpers.findClass("com.snapchat.client.messaging.SavePolicy",
@@ -46,17 +63,19 @@ class UnsaveableMessages : MessagingRuleFeature(
}.getOrNull()
if (savePolicyEnumClass != null && savePolicyEnumClass.isEnum) {
val enumConstants = savePolicyEnumClass.enumConstants
if (enumConstants != null && enumConstants.size > 2) {
val viewSessionEnum = enumConstants[2] as Enum<*>
@Suppress("UNCHECKED_CAST")
val enumClass = savePolicyEnumClass as Class<out Enum<*>>
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) {
@Suppress("UNCHECKED_CAST")
val fieldType = savePolicyField.type as Class<out Enum<*>>
val enumValue = java.lang.Enum.valueOf(fieldType, viewSessionEnum.name)
XposedHelpers.setObjectField(localMessageContent.instanceNonNull(), "mSavePolicy", enumValue)
savePolicyField.isAccessible = true
XposedHelpers.setObjectField(localMessageContent.instanceNonNull(), "mSavePolicy", prohibitedEnum)
}
}
}
@@ -64,25 +83,13 @@ class UnsaveableMessages : MessagingRuleFeature(
context.log.warn("UnsaveableMessages: Failed to set mSavePolicy: ${e.message}")
}
// Modify proto
val messageContentBytes = localMessageContent.content ?: return@subscribe
try {
val protoReader = ProtoReader(messageContentBytes)
val messageContentProto4 = protoReader.followPath(4)
val messageContentProto2 = protoReader.followPath(2)
val targetProto = messageContentProto4 ?: messageContentProto2
if (targetProto == null) return@subscribe
val fieldPath = if (messageContentProto4 != null) 4 else 2
val modifiedContent = ProtoEditor(messageContentBytes).apply {
edit(fieldPath) {
if (getOrNull(7) != null) {
remove(7)
}
addVarInt(7, 3)
addVarInt(7, 1)
}
}.toByteArray()
@@ -99,32 +106,37 @@ class UnsaveableMessages : MessagingRuleFeature(
if (!shouldModifySavePolicy) return@subscribe
try {
event.buffer = ProtoEditor(event.buffer).apply {
edit(4) {
if (getOrNull(7) != null) {
remove(7)
}
addVarInt(7, 3)
}
}.toByteArray()
shouldModifySavePolicy = false
} catch (e: Exception) {
// Try field 2 as fallback
val config = context.config.messaging.unsaveableMessages
val enabledFields = mutableListOf<Int>()
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 protoReader = ProtoReader(event.buffer)
val fieldPath = enabledFields.firstOrNull { fieldId ->
protoReader.followPath(fieldId) != null
} ?: return@subscribe
try {
event.buffer = ProtoEditor(event.buffer).apply {
edit(2) {
edit(fieldPath) {
if (getOrNull(7) != null) {
remove(7)
}
addVarInt(7, 3)
addVarInt(7, 1)
}
}.toByteArray()
shouldModifySavePolicy = false
} catch (e2: Exception) {
context.log.error("UnsaveableMessages: NativeUnaryCallEvent modification failed: ${e2.message}")
} catch (e: Exception) {
context.log.error("UnsaveableMessages: NativeUnaryCallEvent proto modification failed for field $fieldPath: ${e.message}")
}
shouldModifySavePolicy = false
} catch (e: Exception) {
context.log.error("UnsaveableMessages: NativeUnaryCallEvent modification failed: ${e.message}")
}
}
}
}

View File

@@ -3,18 +3,19 @@ package me.eternal.purrfectsnap.core.features.impl.tweaks
import android.view.ViewGroup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.core.PurrfectSnap
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.ui.getComposerContext
import me.eternal.purrfectsnap.core.ui.getValdiContext
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookAdapter
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getId
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.makeFunctionProxy
class VoiceNoteOverride: Feature("Voice Note Override") {
@@ -24,7 +25,20 @@ class VoiceNoteOverride: Feature("Voice Note Override") {
if (!autoDownloadVoiceNotes && !voiceNoteAutoPlay) return
val playbackMap = sortedMapOf<Long, MutableList<Any>>()
val playbackMap = sortedMapOf<Long, Any>()
val classLoader = context.androidContext.classLoader
fun tryFallbackCreateContext(param: HookAdapter): Any? {
val fallbackClass = runCatching {
classLoader.loadClass("com.snapchat.client.composer.NativeBridge")
}.getOrNull() ?: return null
val method = fallbackClass.methods.firstOrNull {
it.name == "createContext" && it.parameterTypes.size == param.args().size
} ?: return null
return runCatching { method.invoke(null, *param.args()) }
.onFailure { context.log.error("Composer NativeBridge fallback failed", it) }
.getOrNull()
}
fun setPlaybackState(componentContext: Any, state: String): Boolean {
val seek = componentContext.getObjectField("_seek") ?: return false
@@ -42,7 +56,7 @@ class VoiceNoteOverride: Feature("Voice Note Override") {
fun getCurrentContextMessageId(currentContext: Any): Long? {
return synchronized(playbackMap) {
playbackMap.entries.firstOrNull { entry -> entry.value.any { it.hashCode() == currentContext.hashCode() } }?.key
playbackMap.entries.lastOrNull { entry -> entry.value.hashCode() == currentContext.hashCode() }?.key
}
}
@@ -59,7 +73,8 @@ class VoiceNoteOverride: Feature("Voice Note Override") {
context.log.verbose("No more voice notes to play")
return
}
nextPlayback.value.toList().forEach { setPlaybackState(it, "PLAYING") }
setPlaybackState(nextPlayback.value, "PLAYING")
}
context.classCache.conversationManager.apply {
@@ -132,24 +147,33 @@ class VoiceNoteOverride: Feature("Voice Note Override") {
}
}
PurrfectSnap.classCache.nativeBridge.hook("createContext", HookStage.AFTER) { param ->
val throwable = param.throwable() as? UnsatisfiedLinkError ?: return@hook
context.log.error("NativeBridge.createContext missing native impl; attempting fallback", throwable)
val fallback = tryFallbackCreateContext(param)
param.setResult(fallback)
}
onNextActivityCreate {
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { _, _ ->
val messagePluginContentHolder = event.view.findViewById<ViewGroup>(context.resources.getId("plugin_content_holder")) ?: return@subscribe
val composerRootView = messagePluginContentHolder.getChildAt(0) ?: return@subscribe
val composerContext = composerRootView.getComposerContext() ?: return@subscribe
val playbackViewComponentContext = composerContext.componentContext?.get() ?: return@subscribe
composerRootView.post {
val composerContext = composerRootView.getValdiContext() ?: return@post
val playbackViewComponentContext = composerContext.componentContext?.get() ?: return@post
if (event.databaseMessage?.contentType != ContentType.NOTE.id) return@subscribe
if (event.databaseMessage?.serverMessageId == 0 || playbackViewComponentContext.getObjectFieldOrNull("_getSamples") == null) return@post
val serverMessageId = event.databaseMessage?.serverMessageId?.toLong() ?: return@subscribe
val serverMessageId = event.databaseMessage?.serverMessageId?.toLong() ?: return@post
synchronized(playbackMap) {
playbackMap.computeIfAbsent(serverMessageId) { mutableListOf() }.add(playbackViewComponentContext)
synchronized(playbackMap) {
playbackMap[serverMessageId] = playbackViewComponentContext
}
}
}
}
}
}
}
}

View File

@@ -3,11 +3,7 @@ package me.eternal.purrfectsnap.core.features.impl.ui
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ViewCompositionStrategy
@@ -18,7 +14,7 @@ import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.getComposerContext
import me.eternal.purrfectsnap.core.ui.getValdiContext
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
class FriendNotes: Feature("Friend Notes") {
@@ -31,7 +27,7 @@ class FriendNotes: Feature("Friend Notes") {
val viewGroup = (event.view as? ViewGroup) ?: return@subscribe
viewGroup.post {
val composerRootView = viewGroup.getChildAt(0) ?: return@post
val composerContext = composerRootView.getComposerContext() ?: return@post
val composerContext = composerRootView.getValdiContext() ?: return@post
val userId = composerContext.viewModel?.getObjectFieldOrNull("_userId")?.toString() ?: return@post
if (userId == context.database.myUserId) return@post

View File

@@ -48,7 +48,9 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
}
}
callbacks.getClass("FetchAndSyncFeedCallback")
callbacks.getAsMap()?.entries?.firstOrNull { it.key.startsWith("FetchAndSyncFeed") && it.key.endsWith("Callback") }
?.value
?.let { findClass(it) }
?.hook("onFetchAndSyncFeedComplete", HookStage.BEFORE) { param ->
val deletedConversations: ArrayList<Any> = param.arg(2)
filterFriendFeed(param.arg(0), deletedConversations)
@@ -59,13 +61,13 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
}) {
param.setArg(4, true)
}
}
} ?: context.log.warn("Failed to hook FetchAndSyncFeedCallback")
callbacks.getClass("SyncFeedCallback")
?.hook("onSyncFeedComplete", HookStage.BEFORE) { param ->
filterFriendFeed(param.arg(0), param.arg(2))
}
} ?: context.log.warn("Failed to hook SyncFeedCallback")
}
}
override fun getRuleState() = RuleState.WHITELIST
}
}

View File

@@ -10,7 +10,7 @@ import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.event.events.impl.BindViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.children
import me.eternal.purrfectsnap.core.ui.getComposerContext
import me.eternal.purrfectsnap.core.ui.getValdiContext
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.ui.onLayoutChange
import me.eternal.purrfectsnap.core.util.dataBuilder
@@ -136,7 +136,7 @@ class UITweaks : Feature("UITweaks") {
if (hiddenElements.contains("hide_billboard_prompt") && event.parent.javaClass.name.endsWith("BillboardFeedHeaderPromptComponent")) {
hideView(event.parent)
view.getComposerContext()?.componentContext?.get()?.dataBuilder {
view.getValdiContext()?.componentContext?.get()?.dataBuilder {
val dismissFunction = get<Any>("_onDismiss") ?: return@subscribe
dismissFunction.javaClass.getMethod("invoke").invoke(dismissFunction)
}
@@ -206,4 +206,4 @@ class UITweaks : Feature("UITweaks") {
onActivityCreate()
}
}
}
}

View File

@@ -2,18 +2,23 @@ package me.eternal.purrfectsnap.core.messaging
import android.util.Base64InputStream
import android.util.Base64OutputStream
import com.google.gson.JsonParser
import com.google.gson.stream.JsonWriter
import kotlinx.coroutines.runBlocking
import me.eternal.purrfectsnap.common.BuildConfig
import me.eternal.purrfectsnap.common.bridge.wrapper.LoggedMessage
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.database.impl.FriendFeedEntry
import me.eternal.purrfectsnap.common.database.impl.FriendInfo
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.common.util.snap.MediaDownloaderHelper
import me.eternal.purrfectsnap.core.ModContext
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment
import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder
import me.eternal.purrfectsnap.core.util.hook.findRestrictedConstructor
import me.eternal.purrfectsnap.core.wrapper.impl.Message
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
import java.io.BufferedInputStream
import java.io.File
import java.io.InputStream
@@ -84,6 +89,9 @@ class ConversationExporter(
jsonDataWriter.beginObject()
jsonDataWriter.name("conversationId").value(friendFeedEntry.key)
jsonDataWriter.name("conversationName").value(friendFeedEntry.feedDisplayName)
exportParams.colorSeedHex?.let { colorSeed ->
jsonDataWriter.name("colorSeed").value(colorSeed)
}
var index = 0
@@ -92,6 +100,7 @@ class ConversationExporter(
conversationParticipants.forEach { (userId, friendInfo) ->
jsonDataWriter.name(userId).beginObject()
jsonDataWriter.name("id").value(index)
jsonDataWriter.name("userId").value(userId)
jsonDataWriter.name("displayName").value(friendInfo.displayName)
jsonDataWriter.name("username").value(friendInfo.usernameForSorting)
jsonDataWriter.name("bitmojiSelfieId").value(friendInfo.bitmojiSelfieId)
@@ -101,6 +110,14 @@ class ConversationExporter(
endObject()
}
exportParams.colorOverrides?.takeIf { it.isNotEmpty() }?.let { overrides ->
jsonDataWriter.name("userColors").beginObject()
overrides.forEach { (userId, color) ->
jsonDataWriter.name(userId).value(color)
}
jsonDataWriter.endObject()
}
jsonDataWriter.name("messages").beginArray()
if (exportParams.exportFormat != ExportFormat.HTML) return
@@ -125,9 +142,21 @@ class ConversationExporter(
private val downloadedMediaIdCache = CopyOnWriteArraySet<String>()
private val pendingDownloadMediaIdCache = CopyOnWriteArraySet<String>()
private fun downloadMedia(message: Message) {
data class LoggedMessageExportData(
val orderKey: Long,
val senderId: String,
val senderUsername: String?,
val contentType: ContentType,
val contentBytes: ByteArray?,
val createdTimestamp: Long,
val readTimestamp: Long?,
val attachments: List<DecodedAttachment>,
val isDeleted: Boolean
)
private fun downloadMedia(attachments: List<DecodedAttachment>) {
downloadThreadExecutor.execute {
MessageDecoder.decode(message.messageContent!!).forEach decode@{ attachment ->
attachments.forEach decode@{ attachment ->
if (attachment.mediaUniqueId in downloadedMediaIdCache || attachment.mediaUniqueId in pendingDownloadMediaIdCache) return@decode
pendingDownloadMediaIdCache.add(attachment.mediaUniqueId!!)
for (i in 0..5) {
@@ -176,6 +205,78 @@ class ConversationExporter(
}
}
private fun writeJsonMessage(
orderKey: Long?,
senderId: String?,
contentType: ContentType,
savedBy: List<String>,
seenBy: List<String>,
openedBy: List<String>,
reactions: Map<String, Long?>,
createdTimestamp: Long?,
readTimestamp: Long?,
serializedContent: String?,
rawContent: ByteArray?,
attachments: List<DecodedAttachment>,
isDeleted: Boolean
) {
jsonDataWriter.apply {
beginObject()
name("orderKey").value(orderKey)
name("senderId").value(participants.getOrDefault(senderId ?: "", -1))
name("type").value(contentType.toString())
fun addUserList(name: String, list: List<String>) {
name(name).beginArray()
list.map { participants.getOrDefault(it, -1) }.forEach { value(it) }
endArray()
}
addUserList("savedBy", savedBy)
addUserList("seenBy", seenBy)
addUserList("openedBy", openedBy)
name("reactions").beginObject()
reactions.forEach { (userId, reactionId) ->
name(participants.getOrDefault(userId, -1).toString()).value(reactionId)
}
endObject()
name("createdTimestamp").value(createdTimestamp)
name("readTimestamp").value(readTimestamp)
name("isDeleted").value(isDeleted)
if (serializedContent != null) {
name("serializedContent").value(serializedContent)
} else {
name("serializedContent").nullValue()
}
if (rawContent != null) {
name("rawContent").value(Base64.UrlSafe.encode(rawContent))
} else {
name("rawContent").nullValue()
}
name("attachments").beginArray()
attachments.forEach attachments@{ attachment ->
beginObject()
name("url").value(attachment.boltKey ?: attachment.directUrl)
name("key").value(attachment.mediaUniqueId)
name("type").value(attachment.type.toString())
name("encryption").apply {
attachment.attachmentInfo?.encryption?.let { encryption ->
beginObject()
name("key").value(encryption.key)
name("iv").value(encryption.iv)
endObject()
} ?: nullValue()
}
endObject()
}
endArray()
endObject()
flush()
}
}
fun readMessage(message: Message) {
if (exportParams.exportFormat == ExportFormat.TEXT) {
val (displayName, senderUsername) = conversationParticipants[message.senderId.toString()]?.let {
@@ -188,6 +289,7 @@ class ConversationExporter(
}
val contentType = message.messageContent?.contentType ?: return
val attachments = MessageDecoder.decode(message.messageContent!!)
if (exportParams.downloadMedias && (contentType == ContentType.NOTE ||
contentType == ContentType.SNAP ||
contentType == ContentType.EXTERNAL_MEDIA ||
@@ -195,56 +297,98 @@ class ConversationExporter(
contentType == ContentType.SHARE ||
contentType == ContentType.MAP_REACTION)
) {
downloadMedia(message)
downloadMedia(attachments)
}
jsonDataWriter.apply {
beginObject()
name("orderKey").value(message.orderKey)
name("senderId").value(participants.getOrDefault(message.senderId.toString(), -1))
name("type").value(message.messageContent!!.contentType.toString())
writeJsonMessage(
orderKey = message.orderKey,
senderId = message.senderId.toString(),
contentType = contentType,
savedBy = message.messageMetadata!!.savedBy!!.map { it.toString() },
seenBy = message.messageMetadata!!.seenBy!!.map { it.toString() },
openedBy = message.messageMetadata!!.openedBy!!.map { it.toString() },
reactions = message.messageMetadata!!.reactions!!.associate { it.userId.toString() to it.reactionId },
createdTimestamp = message.messageMetadata!!.createdAt,
readTimestamp = message.messageMetadata!!.readAt,
serializedContent = message.serialize(),
rawContent = message.messageContent!!.content,
attachments = attachments,
isDeleted = false
)
}
fun addUUIDList(name: String, list: List<SnapUUID>) {
name(name).beginArray()
list.map { participants.getOrDefault(it.toString(), -1) }.forEach { value(it) }
endArray()
}
fun parseLoggedMessage(loggedMessage: LoggedMessage): LoggedMessageExportData? {
val messageObject = runCatching {
JsonParser.parseString(String(loggedMessage.messageData, Charsets.UTF_8)).asJsonObject
}.getOrNull() ?: return null
addUUIDList("savedBy", message.messageMetadata!!.savedBy!!)
addUUIDList("seenBy", message.messageMetadata!!.seenBy!!)
addUUIDList("openedBy", message.messageMetadata!!.openedBy!!)
val messageContent = messageObject.getAsJsonObject("mMessageContent") ?: return null
val contentBytes = messageContent.getAsJsonArray("mContent")?.map { it.asByte }?.toByteArray()
val contentType = messageContent.getAsJsonPrimitive("mContentType")?.asString?.let {
runCatching { ContentType.valueOf(it) }.getOrNull()
} ?: contentBytes?.let { ContentType.fromMessageContainer(ProtoReader(it)) } ?: ContentType.UNKNOWN
name("reactions").beginObject()
message.messageMetadata!!.reactions!!.forEach { reaction ->
name(participants.getOrDefault(reaction.userId.toString(), -1L).toString()).value(reaction.reactionId)
}
endObject()
val metadata = messageObject.getAsJsonObject("mMetadata")
val createdTimestamp = metadata?.getAsJsonPrimitive("mCreatedAt")?.asLong ?: loggedMessage.sendTimestamp
val readTimestamp = metadata?.getAsJsonPrimitive("mReadAt")?.asLong
val orderKey = messageObject.getAsJsonPrimitive("mOrderKey")?.asLong ?: loggedMessage.messageId
val attachments = runCatching { MessageDecoder.decode(messageContent) }.getOrDefault(emptyList())
val isDeleted = runCatching {
val messageLogger = context.feature(MessageLogger::class)
messageLogger.isEnabled && messageLogger.isLoggedMessageDeleted(loggedMessage.messageId)
}.getOrDefault(false)
name("createdTimestamp").value(message.messageMetadata!!.createdAt)
name("readTimestamp").value(message.messageMetadata!!.readAt)
name("serializedContent").value(message.serialize())
name("rawContent").value(Base64.UrlSafe.encode(message.messageContent!!.content!!))
name("attachments").beginArray()
MessageDecoder.decode(message.messageContent!!)
.forEach attachments@{ attachments ->
beginObject()
name("url").value(attachments.boltKey ?: attachments.directUrl)
name("key").value(attachments.mediaUniqueId)
name("type").value(attachments.type.toString())
name("encryption").apply {
attachments.attachmentInfo?.encryption?.let { encryption ->
beginObject()
name("key").value(encryption.key)
name("iv").value(encryption.iv)
endObject()
} ?: nullValue()
}
endObject()
}
endArray()
endObject()
flush()
return LoggedMessageExportData(
orderKey = orderKey,
senderId = loggedMessage.userId,
senderUsername = loggedMessage.username,
contentType = contentType,
contentBytes = contentBytes,
createdTimestamp = createdTimestamp,
readTimestamp = readTimestamp,
attachments = attachments,
isDeleted = isDeleted
)
}
fun readLoggedMessage(data: LoggedMessageExportData) {
val serializedContent = data.contentBytes?.getMessageText(data.contentType)
if (exportParams.exportFormat == ExportFormat.TEXT) {
val (displayName, senderUsername) = conversationParticipants[data.senderId]?.let {
it.displayName to it.mutableUsername
} ?: (data.senderUsername ?: data.senderId) to (data.senderUsername ?: data.senderId)
val date = DateFormat.getDateTimeInstance().format(Date(data.createdTimestamp))
outputFileStream.write("[$date] - $displayName ($senderUsername): ${serializedContent ?: data.contentType.name}\n".toByteArray(Charsets.UTF_8))
return
}
if (exportParams.downloadMedias && (data.contentType == ContentType.NOTE ||
data.contentType == ContentType.SNAP ||
data.contentType == ContentType.EXTERNAL_MEDIA ||
data.contentType == ContentType.STICKER ||
data.contentType == ContentType.SHARE ||
data.contentType == ContentType.MAP_REACTION)
) {
downloadMedia(data.attachments)
}
writeJsonMessage(
orderKey = data.orderKey,
senderId = data.senderId,
contentType = data.contentType,
savedBy = emptyList(),
seenBy = emptyList(),
openedBy = emptyList(),
reactions = emptyMap(),
createdTimestamp = data.createdTimestamp,
readTimestamp = data.readTimestamp,
serializedContent = serializedContent,
rawContent = data.contentBytes,
attachments = data.attachments,
isDeleted = data.isDeleted
)
}
fun awaitDownload() {
@@ -333,4 +477,4 @@ class ConversationExporter(
outputFileStream.flush()
outputFileStream.close()
}
}
}

View File

@@ -7,4 +7,6 @@ class ExportParams(
val messageTypeFilter: List<ContentType>? = null,
val amountOfMessages: Int? = null,
val downloadMedias: Boolean = false,
val colorSeedHex: String? = null,
val colorOverrides: Map<String, String>? = null,
)

View File

@@ -281,6 +281,14 @@ class InAppOverlay(
showDuration: Boolean = true,
maxLines: Int = 3
) {
if (context.config.global.uiSettings.useSystemToasts.get()) {
if (durationMs > 2500) {
context.longToast(text)
} else {
context.shortToast(text)
}
return
}
showToast(
icon = { Icon(icon, contentDescription = "icon", modifier = Modifier.size(32.dp)) },
text = {

View File

@@ -20,7 +20,7 @@ class UserInterface(
val actionSheetBackground get() = if (context.androidContext.isDarkTheme()) 0xff1e1e1e.toInt() else 0xffffffff.toInt()
val avenirNextFontId = 500
val avenirNextTypeface get() = fontMap[avenirNextFontId] ?: fontMap.entries.minByOrNull { it.key }?.value ?: Typeface.DEFAULT
val avenirNextTypeface get() = fontMap[avenirNextFontId] ?: fontMap.entries.filter { !it.value.isItalic }.minByOrNull { it.key }?.value ?: Typeface.DEFAULT
fun dpToPx(dp: Int): Int {
return (dp * context.resources.displayMetrics.density).toInt()
@@ -71,6 +71,7 @@ class UserInterface(
try {
if (context.resources.getResourceTypeName(++offset) != "font") break
val font = runCatching { context.resources.getFont(offset) }.getOrNull() ?: break
if (font.isItalic) continue
fontMap[font.weight] = font
} catch (_: Throwable) {
break

View File

@@ -12,8 +12,9 @@ import android.os.SystemClock
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import me.eternal.purrfectsnap.core.wrapper.impl.composer.ComposerContext
import me.eternal.purrfectsnap.core.wrapper.impl.composer.ComposerViewNode
import me.eternal.purrfectsnap.core.PurrfectSnap
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiContext
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiViewNode
private val foregroundDrawableListTag = randomTag()
@@ -135,32 +136,24 @@ fun View.hideViewCompletely() {
onLayoutChange { hide() }
}
fun View.getComposerViewNode(): ComposerViewNode? {
// Prefer Composer API if present
this::class.java.methods.firstOrNull { it.name == "getComposerViewNode" }?.let { method ->
val node = method.invoke(this) ?: return null
return ComposerViewNode.fromNode(node)
}
// Fallback to Valdi API
this::class.java.methods.firstOrNull { it.name == "getValdiViewNode" && it.parameterTypes.isEmpty() }?.let { method ->
val node = method.invoke(this) ?: return null
return ComposerViewNode.fromNode(node)
}
return null
fun View.getValdiViewNode(): ValdiViewNode? {
val valdiView = PurrfectSnap.classCache.valdiView ?: return null
if (!valdiView.isInstance(this)) return null
val viewNode = this::class.java.methods.firstOrNull {
it.name == "getComposerViewNode" || it.name == "getValdiViewNode"
}?.invoke(this) ?: return null
return ValdiViewNode.fromNode(viewNode)
}
fun View.getComposerContext(): ComposerContext? {
// Prefer Composer API if present
this::class.java.methods.firstOrNull { it.name == "getComposerContext" }?.let { method ->
val ctx = method.invoke(this) ?: return null
return ComposerContext(ctx)
}
// Fallback to Valdi API
this::class.java.methods.firstOrNull { it.name == "getValdiContext" && it.parameterTypes.isEmpty() }?.let { method ->
val ctx = method.invoke(this) ?: return null
return ComposerContext(ctx)
}
return null
fun View.getValdiContext(): ValdiContext? {
val valdiView = PurrfectSnap.classCache.valdiView ?: return null
if (!valdiView.isInstance(this)) return null
return ValdiContext(this::class.java.methods.firstOrNull {
it.name == "getComposerContext" || it.name == "getValdiContext"
}?.invoke(this) ?: return null)
}
object ViewAppearanceHelper {

View File

@@ -386,7 +386,7 @@ class FriendFeedInfoMenu : AbstractMenu() {
fontWeight = if (title) FontWeight.Bold else FontWeight.Normal,
fontSize = if (title) 14.sp else 12.sp,
color = Color.White.copy(alpha = if (title) 0.95f else 0.80f),
maxLines = 1,
maxLines = if (title) 1 else 2,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
)
}

View File

@@ -0,0 +1,34 @@
package me.eternal.purrfectsnap.core.util
object ClassDetector {
fun findClassBySignature(
classLoader: ClassLoader,
knownNames: List<String>,
methodSignature: (Class<*>) -> Boolean
): Class<*>? {
for (className in knownNames) {
runCatching {
val clazz = classLoader.loadClass(className)
if (methodSignature(clazz)) return clazz
}.onFailure { }
}
for (name in knownNames) {
val alternative = when {
name.contains("composer", ignoreCase = true) -> name.replace("composer", "valdi", ignoreCase = true)
name.contains("valdi", ignoreCase = true) -> name.replace("valdi", "composer", ignoreCase = true)
else -> null
}
alternative?.let {
runCatching {
val clazz = classLoader.loadClass(it)
if (methodSignature(clazz)) return clazz
}.onFailure { }
}
}
return null
}
}

View File

@@ -30,7 +30,7 @@ object LSPatchUpdater {
val embeddedModule = context.androidContext.cacheDir
.resolve("lspatch")
.resolve(Constants.SE_PACKAGE_NAME).let { moduleDir ->
.resolve(Constants.MODULE_PACKAGE_NAME).let { moduleDir ->
if (!moduleDir.exists()) return@let null
moduleDir.listFiles()?.firstOrNull { it.extension == "apk" }
} ?: obfuscatedModulePath?.let { path ->
@@ -41,7 +41,7 @@ object LSPatchUpdater {
} ?: return
HAS_LSPATCH = true
context.log.verbose("Found embedded SE at ${embeddedModule.absolutePath}", TAG)
context.log.verbose("Found embedded PurrfectSnap at ${embeddedModule.absolutePath}", TAG)
val seAppApk = File(context.bridgeClient.getApplicationApkPath()).also {
if (!it.canRead()) {
@@ -51,7 +51,7 @@ object LSPatchUpdater {
runCatching {
if (getModuleUniqueHash(ZipFile(embeddedModule)) == getModuleUniqueHash(ZipFile(seAppApk))) {
context.log.verbose("Embedded SE is up to date", TAG)
context.log.verbose("Embedded PurrfectSnap is up to date", TAG)
return
}
}.onFailure {
@@ -76,3 +76,4 @@ object LSPatchUpdater {
context.softRestartApp()
}
}

View File

@@ -0,0 +1,29 @@
package me.eternal.purrfectsnap.core.wrapper.impl.valdi
import de.robv.android.xposed.XposedHelpers
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
import java.lang.ref.WeakReference
import java.lang.reflect.Proxy
class ValdiContext(obj: Any): AbstractWrapper(obj) {
val componentPath by field<String>("componentPath")
val viewModel by field<Any?>("innerViewModel")
val moduleName by field<String>("moduleName")
val componentContext by field<WeakReference<Any?>>("componentContext")
val viewModelLegacy: Any?
get() = runCatching { XposedHelpers.getObjectField(instanceNonNull(), "viewModel") }.getOrNull()
?: instanceNonNull()::class.java.methods.firstOrNull { it.name == "getViewModel" && it.parameterTypes.isEmpty() }?.invoke(instanceNonNull())
fun enqueueNextRenderCallback(callback: () -> Unit) {
val method = instanceNonNull()::class.java.methods.firstOrNull {
it.name == "onNextLayout"
}
method?.invoke(instanceNonNull(), Proxy.newProxyInstance(
instanceNonNull()::class.java.classLoader,
arrayOf(method.parameterTypes[0])
) { _, _, _ ->
callback()
})
}
}

View File

@@ -0,0 +1,17 @@
package me.eternal.purrfectsnap.core.wrapper.impl.valdi
import me.eternal.purrfectsnap.core.PurrfectSnap
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
class ValdiFunction(obj: Any): AbstractWrapper(obj) {
private val performMethod by lazy {
instanceNonNull().javaClass.getMethod(
"perform",
PurrfectSnap.classCache.valdiMarshaller
)
}
fun perform(valdiMarshaller: ValdiMarshaller): Boolean {
return performMethod.invoke(instanceNonNull(), valdiMarshaller.instanceNonNull()) as Boolean
}
}

View File

@@ -0,0 +1,32 @@
package me.eternal.purrfectsnap.core.wrapper.impl.valdi
import me.eternal.purrfectsnap.core.PurrfectSnap
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
import java.io.Closeable
class ValdiMarshaller(obj: Any): AbstractWrapper(obj), Closeable {
companion object {
fun create(): ValdiMarshaller? {
return runCatching {
ValdiMarshaller(PurrfectSnap.classCache.valdiMarshaller?.getMethod("create")?.invoke(null) ?: return null)
}.getOrNull()
}
}
private val getUntypedMethod by lazy { instanceNonNull().javaClass.methods.first { it.name == "getUntyped" } }
private val getSizeMethod by lazy { instanceNonNull().javaClass.methods.first { it.name == "getSize" } }
private val pushUntypedMethod by lazy { instanceNonNull().javaClass.methods.first { it.name == "pushUntyped" } }
private val destroyMethod by lazy { instanceNonNull().javaClass.methods.firstOrNull { it.name == "destroy" } }
fun getUntyped(index: Int): Any? = getUntypedMethod.invoke(instanceNonNull(), index)
fun getSize() = getSizeMethod.invoke(instanceNonNull()) as Int
fun pushUntyped(value: Any?): Any? = pushUntypedMethod.invoke(instanceNonNull(), value)
fun destroy() {
destroyMethod?.invoke(instanceNonNull())
}
override fun close() {
runCatching { destroy() }
}
}

View File

@@ -0,0 +1,45 @@
package me.eternal.purrfectsnap.core.wrapper.impl.valdi
import me.eternal.purrfectsnap.core.PurrfectSnap
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
class ValdiViewNode(obj: Long) : AbstractWrapper(obj) {
companion object {
fun fromNode(viewNode: Any?): ValdiViewNode? {
return (viewNode?.javaClass?.methods?.firstOrNull {
it.name == "getNativeHandle"
}?.invoke(viewNode) as? Long)?.let { ValdiViewNode(it) } ?: return null
}
}
fun getAttribute(name: String): Any? {
return PurrfectSnap.classCache.nativeBridge?.methods?.firstOrNull {
it.name == "getValueForAttribute"
}?.invoke(null, instanceNonNull(), name)
}
fun setAttribute(name: String, value: Any) {
PurrfectSnap.classCache.nativeBridge?.methods?.firstOrNull {
it.name == "setValueForAttribute"
}?.invoke(null, instanceNonNull(), name, value, false)
}
fun getChildren(): List<ValdiViewNode> {
val children = PurrfectSnap.classCache.nativeBridge?.methods?.firstOrNull {
it.name == "getRetainedViewNodeChildren"
}?.invoke(null, instanceNonNull(), 1) as? LongArray ?: return emptyList()
return children.map { ValdiViewNode(it) }
}
fun getClassName(): String {
return PurrfectSnap.classCache.nativeBridge?.methods?.firstOrNull {
it.name == "getViewClassName"
}?.invoke(null, instanceNonNull())?.toString() ?: ""
}
override fun toString(): String {
return PurrfectSnap.classCache.nativeBridge?.methods?.firstOrNull {
it.name == "getViewNodeDebugDescription"
}?.invoke(null, instanceNonNull())?.toString() ?: ""
}
}