v1.1.0: Stable!
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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() ?: ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user