fix custom emoji & call recorder quality,add order feature for chat exports

This commit is contained in:
particle-box
2026-02-07 00:44:00 +05:30
parent 3744c5b422
commit a21b938be0
9 changed files with 254 additions and 62 deletions

View File

@@ -232,6 +232,7 @@ class FFMpegProcessor(
}
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
outputArguments += "-c:a" to "pcm_s16le"
}
Action.MERGE_AUDIO_STREAMS -> {
inputArguments.clear()
@@ -240,18 +241,23 @@ class FFMpegProcessor(
args.inputs.forEachIndexed { index, input ->
inputArguments += "-i" to input
val offset = args.inputDelayOffsets?.get(input) ?: 0L
filterParts.append("[$index:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo")
if (offset > 0) {
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
filterParts.append(",adelay=$offset|$offset[a$index];")
} else {
filterParts.append("[$index:a]acopy[a$index];")
filterParts.append(",acopy[a$index];")
}
}
args.inputs.indices.forEach { index ->
filterParts.append("[a$index]")
}
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:normalize=0[aout]")
filterParts.append("amix=inputs=${args.inputs.size}:duration=longest:dropout_transition=0:normalize=1,alimiter=limit=0.95[aout]")
outputArguments += "-filter_complex" to "\"$filterParts\""
outputArguments += "-map" to "\"[aout]\""
outputArguments += "-c:a" to "libmp3lame"
outputArguments += "-b:a" to "192k"
outputArguments += "-ar" to "48000"
outputArguments += "-ac" to "2"
}
}
outputArguments += args.output.absolutePath

View File

@@ -46,7 +46,7 @@ class CallDownloadSessionImpl(
val job: Job
val writePfd: ParcelFileDescriptor
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.mp3").apply {
val outputFile = context.androidContext.cacheDir.resolve("call_${UUID.randomUUID()}.wav").apply {
if (exists()) delete()
}

View File

@@ -537,7 +537,8 @@
},
"file_imports": {
"no_files_settings_hint": "No files found. Make sure you have imported the required files in the File Imports section",
"settings_select_file_hint": "Select an imported file"
"settings_select_file_hint": "Select an imported file",
"settings_select_file_subtitle": "Choose a file from your imported files list"
}
},
"scripting": {
@@ -2910,6 +2911,9 @@
"text_field_selection": "{amount} selected",
"text_field_selection_all": "All",
"export_file_format_title": "Export File Format",
"sort_order_title": "Message Order",
"sort_order_newest_to_oldest": "Newest to Oldest",
"sort_order_oldest_to_newest": "Oldest to Newest",
"message_type_filter_title": "Filter Messages by Type",
"amount_of_messages_title": "Message Count (leave blank for all)",
"download_medias_title": "Download Media"

View File

@@ -74,6 +74,7 @@ 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.messaging.ExportSortOrder
import me.eternal.purrfectsnap.core.wrapper.impl.Message
import java.io.File
import kotlin.math.absoluteValue
@@ -180,7 +181,9 @@ class ExportChatMessages : AbstractAction() {
var downloadMedias by remember { mutableStateOf(false) }
var showConversationPicker by remember { mutableStateOf(false) }
var showFormatPicker by remember { mutableStateOf(false) }
var showOrderPicker by remember { mutableStateOf(false) }
var showMessageTypePicker by remember { mutableStateOf(false) }
var exportSortOrder by remember { mutableStateOf(ExportSortOrder.NEWEST_TO_OLDEST) }
val colorOverrides = remember { mutableStateMapOf<String, String>() }
var colorPickerTarget by remember { mutableStateOf<ExportColorParticipant?>(null) }
var colorPickerValue by remember { mutableStateOf<Color?>(null) }
@@ -309,6 +312,19 @@ class ExportChatMessages : AbstractAction() {
tint = MaterialTheme.colorScheme.surfaceVariant
)
SectionLabel(t("sort_order_title"))
GlassField(
value = t(
if (exportSortOrder == ExportSortOrder.OLDEST_TO_NEWEST) {
"sort_order_oldest_to_newest"
} else {
"sort_order_newest_to_oldest"
}
),
onClick = { showOrderPicker = true },
tint = MaterialTheme.colorScheme.surfaceVariant
)
SectionLabel(t("message_type_filter_title"))
GlassField(
value = messageTypeFilter.takeIf { it.isNotEmpty() }?.let {
@@ -417,6 +433,7 @@ class ExportChatMessages : AbstractAction() {
selection,
ExportParams(
exportFormat = exportType,
sortOrder = exportSortOrder,
messageTypeFilter = messageTypeFilter.takeIf { it.isNotEmpty() },
amountOfMessages = amountOfMessages.takeIf { it != -1 },
downloadMedias = downloadMedias,
@@ -610,6 +627,38 @@ class ExportChatMessages : AbstractAction() {
)
}
}
if (showOrderPicker) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.55f))
.clickable { showOrderPicker = false }
)
SelectorPopup(
title = t("sort_order_title"),
onDismiss = { showOrderPicker = false }
) {
SelectorRow(
title = t("sort_order_newest_to_oldest"),
subtitle = null,
checked = exportSortOrder == ExportSortOrder.NEWEST_TO_OLDEST,
onToggle = {
exportSortOrder = ExportSortOrder.NEWEST_TO_OLDEST
showOrderPicker = false
}
)
SelectorRow(
title = t("sort_order_oldest_to_newest"),
subtitle = null,
checked = exportSortOrder == ExportSortOrder.OLDEST_TO_NEWEST,
onToggle = {
exportSortOrder = ExportSortOrder.OLDEST_TO_NEWEST
showOrderPicker = false
}
)
}
}
}
}
@@ -1159,6 +1208,13 @@ class ExportChatMessages : AbstractAction() {
}.getOrDefault(emptyList())
}
private fun messageSortKey(message: Message): Long {
return message.orderKey
?: message.messageMetadata?.createdAt
?: message.messageDescriptor?.messageId
?: Long.MIN_VALUE
}
private suspend fun exportFullConversation(
feedEntry: FriendFeedEntry,
exportParams: ExportParams,
@@ -1209,58 +1265,75 @@ class ExportChatMessages : AbstractAction() {
var foundMessageCount = 0
val exportedOrderKeys = mutableSetOf<Long>()
val fetchedMessages = mutableListOf<Message>()
val seenMessageKeys = mutableSetOf<String>()
var lastMessageId: Long? = null
fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also { message ->
conversationExporter.readMessage(message)
foundMessageCount++
message.orderKey?.let { exportedOrderKeys.add(it) }
val messageKey = message.orderKey?.toString() ?: message.messageDescriptor?.messageId?.toString()
if (messageKey != null && seenMessageKeys.add(messageKey)) {
fetchedMessages.add(message)
}
lastMessageId = message.messageDescriptor?.messageId
}
if (lastMessageId == null) {
if (lastMessageId == null && fetchedMessages.isEmpty()) {
logDialog(translation["no_messages_found"])
}
while (lastMessageId != null) {
val fetchedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500).toMutableList()
if (fetchedMessages.isEmpty()) break
val pagedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500)
if (pagedMessages.isEmpty()) break
fetchedMessages.firstOrNull()?.let {
pagedMessages.firstOrNull()?.let {
lastMessageId = it.messageDescriptor!!.messageId!!
}
exportParams.messageTypeFilter?.let { filter ->
fetchedMessages.removeIf { message ->
!filter.contains(message.messageContent?.contentType ?: return@removeIf false)
pagedMessages.forEach { message ->
val messageKey = message.orderKey?.toString() ?: message.messageDescriptor?.messageId?.toString()
if (messageKey != null && seenMessageKeys.add(messageKey)) {
fetchedMessages.add(message)
}
}
}
val remainingLimit = exportParams.amountOfMessages?.let { it - foundMessageCount } ?: Int.MAX_VALUE
if (remainingLimit <= 0) break
val messagesToWrite = fetchedMessages.reversed().let { messages ->
if (messages.size <= remainingLimit) messages else messages.subList(0, remainingLimit)
val filteredMessages = exportParams.messageTypeFilter?.let { filter ->
fetchedMessages.filter { message ->
val contentType = message.messageContent?.contentType ?: return@filter false
filter.contains(contentType)
}
} ?: fetchedMessages
messagesToWrite.forEach { message ->
conversationExporter.readMessage(message)
foundMessageCount++
message.orderKey?.let { exportedOrderKeys.add(it) }
}
val sortedMessages = when (exportParams.sortOrder) {
ExportSortOrder.OLDEST_TO_NEWEST -> filteredMessages.sortedBy { messageSortKey(it) }
ExportSortOrder.NEWEST_TO_OLDEST -> filteredMessages.sortedByDescending { messageSortKey(it) }
}
val messagesToWrite = exportParams.amountOfMessages?.let { limit ->
sortedMessages.take(limit)
} ?: sortedMessages
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()) {
val sortedLoggerMessages = when (exportParams.sortOrder) {
ExportSortOrder.OLDEST_TO_NEWEST -> parsedLoggerMessages.sortedBy { it.orderKey }
ExportSortOrder.NEWEST_TO_OLDEST -> parsedLoggerMessages.sortedByDescending { it.orderKey }
}
for (loggedMessage in sortedLoggerMessages) {
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++
setStatus("Exporting (found ${foundMessageCount})")
}
}

View File

@@ -184,6 +184,29 @@ class CallRecorder : Feature("Call Recorder") {
onCallStarted(conversationId)
}
private fun clampCopyRange(offset: Int, requestedLength: Int, maxLength: Int): Pair<Int, Int>? {
if (requestedLength <= 0 || maxLength <= 0) return null
val safeOffset = offset.coerceAtLeast(0).coerceAtMost(maxLength)
val available = (maxLength - safeOffset).coerceAtLeast(0)
val safeLength = requestedLength.coerceAtMost(available)
if (safeLength <= 0) return null
return safeOffset to safeLength
}
private fun copyAudioRecordByteBuffer(data: ByteBuffer, bytesRead: Int): ByteArray? {
if (bytesRead <= 0) return null
val currentPosition = data.position()
val start = (currentPosition - bytesRead).coerceAtLeast(0)
val end = currentPosition.coerceAtMost(data.limit())
if (end <= start) return null
return ByteArray(end - start).also { out ->
val dup = data.duplicate()
dup.position(start)
dup.limit(end)
dup.get(out)
}
}
override fun init() {
if (callRecorderConfig.callRecorder.getNullable() == null) return
@@ -221,10 +244,18 @@ class CallRecorder : Feature("Call Recorder") {
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val buffer = when (val data = param.arg<Any>(0)) {
is ByteBuffer -> ByteArray(result).also { val pos = data.position(); data.get(it); data.position(pos) }
is ByteArray -> data.copyOfRange(param.argNullable(1) ?: 0, (param.argNullable<Int>(1) ?: 0) + result)
is ShortArray -> ByteArray(result * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, param.argNullable(1) ?: 0, result)
is ByteBuffer -> copyAudioRecordByteBuffer(data, result) ?: return@hook
is ByteArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val (safeOffset, safeLength) = clampCopyRange(offset, result, data.size) ?: return@hook
data.copyOfRange(safeOffset, safeOffset + safeLength)
}
is ShortArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val (safeOffset, safeLength) = clampCopyRange(offset, result, data.size) ?: return@hook
ByteArray(safeLength * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
}
}
else -> return@hook
}
@@ -260,14 +291,31 @@ class CallRecorder : Feature("Call Recorder") {
hook("write", HookStage.BEFORE) { param ->
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val data = param.arg<Any>(0)
val size = if (param.args().size > 2) param.arg(2) else if (data is ByteArray) data.size else if (data is ShortArray) data.size else if (data is ByteBuffer) data.remaining() else 0
if (size <= 0) return@hook
val buffer = when (data) {
is ByteBuffer -> ByteArray(size).also { val pos = data.position(); data.get(it); data.position(pos) }
is ByteArray -> data.copyOfRange(param.argNullable(1) ?: 0, (param.argNullable<Int>(1) ?: 0) + size)
is ShortArray -> ByteArray(size * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, param.argNullable(1) ?: 0, size)
is ByteBuffer -> {
val requestedSize = param.argNullable<Int>(1) ?: data.remaining()
val safeSize = requestedSize.coerceAtMost(data.remaining()).coerceAtLeast(0)
if (safeSize <= 0) return@hook
ByteArray(safeSize).also {
val pos = data.position()
data.get(it, 0, safeSize)
data.position(pos)
}
}
is ByteArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val requestedSize = param.argNullable<Int>(2) ?: data.size
val (safeOffset, safeLength) = clampCopyRange(offset, requestedSize, data.size) ?: return@hook
data.copyOfRange(safeOffset, safeOffset + safeLength)
}
is ShortArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val requestedSize = param.argNullable<Int>(2) ?: data.size
val (safeOffset, safeLength) = clampCopyRange(offset, requestedSize, data.size) ?: return@hook
ByteArray(safeLength * 2).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
}
}
else -> return@hook
}

View File

@@ -1,52 +1,84 @@
package me.eternal.purrfectsnap.core.features.impl.experiments
import android.graphics.Typeface
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
import me.eternal.purrfectsnap.core.ModContext
import java.io.File
import java.io.FileOutputStream
private var cacheFontPath: String? = null
private var cacheFontName: String? = null
private var cachedFontPath: String? = null
private var cachedFontName: String? = null
fun getCustomEmojiFontPath(
context: ModContext
): String? {
val customFileName = context.config.experimental.nativeHooks.customEmojiFont.getNullable()?.takeIf { it.isNotBlank() } ?: return null
val safeFileName = customFileName.substringAfterLast("/")
val customFileName = context.config.experimental.nativeHooks.customEmojiFont.getNullable()
?.trim()
?.takeIf { it.isNotBlank() } ?: return null
val safeFileName = customFileName.substringAfterLast("/").substringAfterLast("\\")
if (safeFileName.isBlank()) return null
fun clearCachedPath(cacheFile: File? = null): String? {
runCatching { cacheFile?.delete() }
cachedFontPath = null
cachedFontName = safeFileName
return null
}
fun validateFontFile(file: File): Boolean {
return runCatching {
Typeface.createFromFile(file)
true
}.onFailure {
context.log.warn("Custom emoji font validation failed for ${file.absolutePath}: ${it.message}")
}.getOrDefault(false)
}
return runCatching {
val handle = context.fileHandlerManager.getFileHandle(
FileHandleScope.USER_IMPORT.key,
customFileName
) ?: return@runCatching null
) ?: return@runCatching clearCachedPath()
if (!handle.exists()) {
File(context.androidContext.filesDir, "emoji_fonts")
.resolve(safeFileName)
.takeIf { it.exists() }
?.delete()
cacheFontPath = ""
cacheFontName = safeFileName
return@runCatching null
val oldCacheFile = File(context.androidContext.cacheDir, "emoji_fonts").resolve(safeFileName)
return@runCatching clearCachedPath(oldCacheFile)
}
val persistentDir = File(context.androidContext.filesDir, "emoji_fonts").apply {
val persistentDir = File(context.androidContext.cacheDir, "emoji_fonts").apply {
mkdirs()
}
val persistentFile = File(persistentDir, safeFileName)
handle.open(android.os.ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
if (!persistentFile.exists() || pfd.statSize != persistentFile.length()) {
FileOutputStream(persistentFile).use { output ->
val needsCopy = handle.open(android.os.ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
val sourceSize = pfd.statSize
!persistentFile.exists() || (sourceSize > 0 && sourceSize != persistentFile.length())
} ?: true
if (needsCopy) {
val tempFile = File(persistentDir, "$safeFileName.tmp")
handle.open(android.os.ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
FileOutputStream(tempFile).use { output ->
android.os.ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
input.copyTo(output)
}
}
}
if (!tempFile.renameTo(persistentFile)) {
tempFile.copyTo(persistentFile, overwrite = true)
tempFile.delete()
}
}
cacheFontName = safeFileName
cacheFontPath = persistentFile.absolutePath
cacheFontPath?.takeIf { it.isNotEmpty() }
if (!validateFontFile(persistentFile)) {
return@runCatching clearCachedPath(persistentFile)
}
cachedFontName = safeFileName
cachedFontPath = persistentFile.absolutePath
cachedFontPath?.takeIf { it.isNotEmpty() }
}.onFailure {
context.log.error("Failed to get custom emoji font", it)
clearCachedPath()
}.getOrNull()
}

View File

@@ -2,8 +2,14 @@ package me.eternal.purrfectsnap.core.messaging
import me.eternal.purrfectsnap.common.data.ContentType
enum class ExportSortOrder {
NEWEST_TO_OLDEST,
OLDEST_TO_NEWEST
}
class ExportParams(
val exportFormat: ExportFormat = ExportFormat.HTML,
val sortOrder: ExportSortOrder = ExportSortOrder.NEWEST_TO_OLDEST,
val messageTypeFilter: List<ContentType>? = null,
val amountOfMessages: Int? = null,
val downloadMedias: Boolean = false,

View File

@@ -11,6 +11,16 @@ object LSPatchUpdater {
var HAS_LSPATCH = false
private set
private fun ensureTranslationsLoaded(context: ModContext) {
if (context.translation.getOrNull("toast_purrfectsnap_updated") != null) return
runCatching {
context.translation.userLocale = context.getConfigLocale()
context.translation.load()
}.onFailure {
context.log.warn("Failed to load translations in updater: ${it.message}", TAG)
}
}
private fun getModuleUniqueHash(module: ZipFile): String {
return module.entries().asSequence()
.filter { !it.isDirectory }
@@ -20,6 +30,8 @@ object LSPatchUpdater {
}
fun onBridgeConnected(context: ModContext) {
ensureTranslationsLoaded(context)
val obfuscatedModulePath by lazy {
(runCatching {
context::class.java.classLoader?.loadClass("org.lsposed.lspatch.share.Constants")
@@ -59,19 +71,19 @@ object LSPatchUpdater {
}
context.log.verbose("updating", TAG)
context.shortToast(context.translation["toast_updating_purrfectsnap"])
context.shortToast(context.translation.getOrNull("toast_updating_purrfectsnap") ?: "Updating PurrfectSnap. Please wait...")
// copy embedded module to cache
runCatching {
seAppApk.copyTo(embeddedModule, overwrite = true)
}.onFailure {
seAppApk.delete()
context.log.error("Failed to copy embedded module", it, TAG)
context.longToast(context.translation["toast_update_purrfectsnap_failed"])
context.longToast(context.translation.getOrNull("toast_update_purrfectsnap_failed") ?: "Failed to update PurrfectSnap. Please check logcat for more details.")
context.forceCloseApp()
return
}
context.longToast(context.translation["toast_purrfectsnap_updated"])
context.longToast(context.translation.getOrNull("toast_purrfectsnap_updated") ?: "PurrfectSnap updated!")
context.log.verbose("updated", TAG)
context.softRestartApp()
}

View File

@@ -1,4 +1,4 @@
use std::{ffi::CStr, fs};
use std::{ffi::{CStr, CString}, fs};
use nix::libc::{self, c_uint};
@@ -12,7 +12,18 @@ def_hook!(
if pathname == "/system/fonts/NotoColorEmoji.ttf" {
if let Some(font_path) = config::native_config().custom_emoji_font_path {
if fs::metadata(&font_path).is_ok() {
return libc::openat(libc::AT_FDCWD, font_path.as_ptr() as *const u8, flags, mode);
match CString::new(font_path.clone()) {
Ok(c_font_path) => {
let fd = libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode);
if fd >= 0 {
return fd;
}
warn!("failed to open custom emoji font path (fd={}): {}", fd, font_path);
}
Err(_) => {
warn!("custom emoji font path contains null byte, using fallback system font");
}
}
} else {
warn!("custom emoji font path does not exist: {}", font_path);
}
@@ -31,4 +42,4 @@ pub fn init() {
}
dobby_hook_sym!("libc.so", "open", open_hook);
}
}