fix custom emoji & call recorder quality,add order feature for chat exports
This commit is contained in:
@@ -232,6 +232,7 @@ class FFMpegProcessor(
|
|||||||
}
|
}
|
||||||
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
|
globalArguments += "-ar" to args.audioStreamFormat.sampleRate.toString()
|
||||||
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
|
globalArguments += "-ac" to args.audioStreamFormat.channels.toString()
|
||||||
|
outputArguments += "-c:a" to "pcm_s16le"
|
||||||
}
|
}
|
||||||
Action.MERGE_AUDIO_STREAMS -> {
|
Action.MERGE_AUDIO_STREAMS -> {
|
||||||
inputArguments.clear()
|
inputArguments.clear()
|
||||||
@@ -240,18 +241,23 @@ class FFMpegProcessor(
|
|||||||
args.inputs.forEachIndexed { index, input ->
|
args.inputs.forEachIndexed { index, input ->
|
||||||
inputArguments += "-i" to input
|
inputArguments += "-i" to input
|
||||||
val offset = args.inputDelayOffsets?.get(input) ?: 0L
|
val offset = args.inputDelayOffsets?.get(input) ?: 0L
|
||||||
|
filterParts.append("[$index:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo")
|
||||||
if (offset > 0) {
|
if (offset > 0) {
|
||||||
filterParts.append("[$index:a]adelay=$offset|$offset[a$index];")
|
filterParts.append(",adelay=$offset|$offset[a$index];")
|
||||||
} else {
|
} else {
|
||||||
filterParts.append("[$index:a]acopy[a$index];")
|
filterParts.append(",acopy[a$index];")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
args.inputs.indices.forEach { index ->
|
args.inputs.indices.forEach { index ->
|
||||||
filterParts.append("[a$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 += "-filter_complex" to "\"$filterParts\""
|
||||||
outputArguments += "-map" to "\"[aout]\""
|
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
|
outputArguments += args.output.absolutePath
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class CallDownloadSessionImpl(
|
|||||||
val job: Job
|
val job: Job
|
||||||
val writePfd: ParcelFileDescriptor
|
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()
|
if (exists()) delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -537,7 +537,8 @@
|
|||||||
},
|
},
|
||||||
"file_imports": {
|
"file_imports": {
|
||||||
"no_files_settings_hint": "No files found. Make sure you have imported the required files in the File Imports section",
|
"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": {
|
"scripting": {
|
||||||
@@ -2910,6 +2911,9 @@
|
|||||||
"text_field_selection": "{amount} selected",
|
"text_field_selection": "{amount} selected",
|
||||||
"text_field_selection_all": "All",
|
"text_field_selection_all": "All",
|
||||||
"export_file_format_title": "Export File Format",
|
"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",
|
"message_type_filter_title": "Filter Messages by Type",
|
||||||
"amount_of_messages_title": "Message Count (leave blank for all)",
|
"amount_of_messages_title": "Message Count (leave blank for all)",
|
||||||
"download_medias_title": "Download Media"
|
"download_medias_title": "Download Media"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import me.eternal.purrfectsnap.core.logger.CoreLogger
|
|||||||
import me.eternal.purrfectsnap.core.messaging.ConversationExporter
|
import me.eternal.purrfectsnap.core.messaging.ConversationExporter
|
||||||
import me.eternal.purrfectsnap.core.messaging.ExportFormat
|
import me.eternal.purrfectsnap.core.messaging.ExportFormat
|
||||||
import me.eternal.purrfectsnap.core.messaging.ExportParams
|
import me.eternal.purrfectsnap.core.messaging.ExportParams
|
||||||
|
import me.eternal.purrfectsnap.core.messaging.ExportSortOrder
|
||||||
import me.eternal.purrfectsnap.core.wrapper.impl.Message
|
import me.eternal.purrfectsnap.core.wrapper.impl.Message
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
@@ -180,7 +181,9 @@ class ExportChatMessages : AbstractAction() {
|
|||||||
var downloadMedias by remember { mutableStateOf(false) }
|
var downloadMedias by remember { mutableStateOf(false) }
|
||||||
var showConversationPicker by remember { mutableStateOf(false) }
|
var showConversationPicker by remember { mutableStateOf(false) }
|
||||||
var showFormatPicker by remember { mutableStateOf(false) }
|
var showFormatPicker by remember { mutableStateOf(false) }
|
||||||
|
var showOrderPicker by remember { mutableStateOf(false) }
|
||||||
var showMessageTypePicker 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>() }
|
val colorOverrides = remember { mutableStateMapOf<String, String>() }
|
||||||
var colorPickerTarget by remember { mutableStateOf<ExportColorParticipant?>(null) }
|
var colorPickerTarget by remember { mutableStateOf<ExportColorParticipant?>(null) }
|
||||||
var colorPickerValue by remember { mutableStateOf<Color?>(null) }
|
var colorPickerValue by remember { mutableStateOf<Color?>(null) }
|
||||||
@@ -309,6 +312,19 @@ class ExportChatMessages : AbstractAction() {
|
|||||||
tint = MaterialTheme.colorScheme.surfaceVariant
|
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"))
|
SectionLabel(t("message_type_filter_title"))
|
||||||
GlassField(
|
GlassField(
|
||||||
value = messageTypeFilter.takeIf { it.isNotEmpty() }?.let {
|
value = messageTypeFilter.takeIf { it.isNotEmpty() }?.let {
|
||||||
@@ -417,6 +433,7 @@ class ExportChatMessages : AbstractAction() {
|
|||||||
selection,
|
selection,
|
||||||
ExportParams(
|
ExportParams(
|
||||||
exportFormat = exportType,
|
exportFormat = exportType,
|
||||||
|
sortOrder = exportSortOrder,
|
||||||
messageTypeFilter = messageTypeFilter.takeIf { it.isNotEmpty() },
|
messageTypeFilter = messageTypeFilter.takeIf { it.isNotEmpty() },
|
||||||
amountOfMessages = amountOfMessages.takeIf { it != -1 },
|
amountOfMessages = amountOfMessages.takeIf { it != -1 },
|
||||||
downloadMedias = downloadMedias,
|
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())
|
}.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun messageSortKey(message: Message): Long {
|
||||||
|
return message.orderKey
|
||||||
|
?: message.messageMetadata?.createdAt
|
||||||
|
?: message.messageDescriptor?.messageId
|
||||||
|
?: Long.MIN_VALUE
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun exportFullConversation(
|
private suspend fun exportFullConversation(
|
||||||
feedEntry: FriendFeedEntry,
|
feedEntry: FriendFeedEntry,
|
||||||
exportParams: ExportParams,
|
exportParams: ExportParams,
|
||||||
@@ -1209,58 +1265,75 @@ class ExportChatMessages : AbstractAction() {
|
|||||||
|
|
||||||
var foundMessageCount = 0
|
var foundMessageCount = 0
|
||||||
val exportedOrderKeys = mutableSetOf<Long>()
|
val exportedOrderKeys = mutableSetOf<Long>()
|
||||||
|
val fetchedMessages = mutableListOf<Message>()
|
||||||
|
val seenMessageKeys = mutableSetOf<String>()
|
||||||
|
|
||||||
var lastMessageId: Long? = null
|
var lastMessageId: Long? = null
|
||||||
fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also { message ->
|
fetchMessagesPaginated(conversationId, Long.MAX_VALUE, amount = 1).firstOrNull()?.also { message ->
|
||||||
conversationExporter.readMessage(message)
|
val messageKey = message.orderKey?.toString() ?: message.messageDescriptor?.messageId?.toString()
|
||||||
foundMessageCount++
|
if (messageKey != null && seenMessageKeys.add(messageKey)) {
|
||||||
message.orderKey?.let { exportedOrderKeys.add(it) }
|
fetchedMessages.add(message)
|
||||||
|
}
|
||||||
lastMessageId = message.messageDescriptor?.messageId
|
lastMessageId = message.messageDescriptor?.messageId
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastMessageId == null) {
|
if (lastMessageId == null && fetchedMessages.isEmpty()) {
|
||||||
logDialog(translation["no_messages_found"])
|
logDialog(translation["no_messages_found"])
|
||||||
}
|
}
|
||||||
|
|
||||||
while (lastMessageId != null) {
|
while (lastMessageId != null) {
|
||||||
val fetchedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500).toMutableList()
|
val pagedMessages = fetchMessagesPaginated(conversationId, lastMessageId, amount = 500)
|
||||||
if (fetchedMessages.isEmpty()) break
|
if (pagedMessages.isEmpty()) break
|
||||||
|
|
||||||
fetchedMessages.firstOrNull()?.let {
|
pagedMessages.firstOrNull()?.let {
|
||||||
lastMessageId = it.messageDescriptor!!.messageId!!
|
lastMessageId = it.messageDescriptor!!.messageId!!
|
||||||
}
|
}
|
||||||
|
|
||||||
exportParams.messageTypeFilter?.let { filter ->
|
pagedMessages.forEach { message ->
|
||||||
fetchedMessages.removeIf { message ->
|
val messageKey = message.orderKey?.toString() ?: message.messageDescriptor?.messageId?.toString()
|
||||||
!filter.contains(message.messageContent?.contentType ?: return@removeIf false)
|
if (messageKey != null && seenMessageKeys.add(messageKey)) {
|
||||||
|
fetchedMessages.add(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val remainingLimit = exportParams.amountOfMessages?.let { it - foundMessageCount } ?: Int.MAX_VALUE
|
val filteredMessages = exportParams.messageTypeFilter?.let { filter ->
|
||||||
if (remainingLimit <= 0) break
|
fetchedMessages.filter { message ->
|
||||||
|
val contentType = message.messageContent?.contentType ?: return@filter false
|
||||||
val messagesToWrite = fetchedMessages.reversed().let { messages ->
|
filter.contains(contentType)
|
||||||
if (messages.size <= remainingLimit) messages else messages.subList(0, remainingLimit)
|
|
||||||
}
|
}
|
||||||
|
} ?: fetchedMessages
|
||||||
|
|
||||||
messagesToWrite.forEach { message ->
|
val sortedMessages = when (exportParams.sortOrder) {
|
||||||
conversationExporter.readMessage(message)
|
ExportSortOrder.OLDEST_TO_NEWEST -> filteredMessages.sortedBy { messageSortKey(it) }
|
||||||
foundMessageCount++
|
ExportSortOrder.NEWEST_TO_OLDEST -> filteredMessages.sortedByDescending { messageSortKey(it) }
|
||||||
message.orderKey?.let { exportedOrderKeys.add(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})")
|
setStatus("Exporting (found ${foundMessageCount})")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loggerMessages.isNotEmpty() && (exportParams.amountOfMessages == null || foundMessageCount < exportParams.amountOfMessages)) {
|
if (loggerMessages.isNotEmpty() && (exportParams.amountOfMessages == null || foundMessageCount < exportParams.amountOfMessages)) {
|
||||||
val parsedLoggerMessages = loggerMessages.mapNotNull { conversationExporter.parseLoggedMessage(it) }
|
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
|
if (exportedOrderKeys.contains(loggedMessage.orderKey)) continue
|
||||||
val filter = exportParams.messageTypeFilter
|
val filter = exportParams.messageTypeFilter
|
||||||
if (filter != null && !filter.contains(loggedMessage.contentType)) continue
|
if (filter != null && !filter.contains(loggedMessage.contentType)) continue
|
||||||
if (exportParams.amountOfMessages != null && foundMessageCount >= exportParams.amountOfMessages) break
|
if (exportParams.amountOfMessages != null && foundMessageCount >= exportParams.amountOfMessages) break
|
||||||
conversationExporter.readLoggedMessage(loggedMessage)
|
conversationExporter.readLoggedMessage(loggedMessage)
|
||||||
foundMessageCount++
|
foundMessageCount++
|
||||||
|
setStatus("Exporting (found ${foundMessageCount})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,29 @@ class CallRecorder : Feature("Call Recorder") {
|
|||||||
onCallStarted(conversationId)
|
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() {
|
override fun init() {
|
||||||
if (callRecorderConfig.callRecorder.getNullable() == null) return
|
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 wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
||||||
|
|
||||||
val buffer = when (val data = param.arg<Any>(0)) {
|
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 ByteBuffer -> copyAudioRecordByteBuffer(data, result) ?: return@hook
|
||||||
is ByteArray -> data.copyOfRange(param.argNullable(1) ?: 0, (param.argNullable<Int>(1) ?: 0) + result)
|
is ByteArray -> {
|
||||||
is ShortArray -> ByteArray(result * 2).also {
|
val offset = param.argNullable<Int>(1) ?: 0
|
||||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, param.argNullable(1) ?: 0, result)
|
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
|
else -> return@hook
|
||||||
}
|
}
|
||||||
@@ -260,14 +291,31 @@ class CallRecorder : Feature("Call Recorder") {
|
|||||||
hook("write", HookStage.BEFORE) { param ->
|
hook("write", HookStage.BEFORE) { param ->
|
||||||
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
|
||||||
val data = param.arg<Any>(0)
|
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) {
|
val buffer = when (data) {
|
||||||
is ByteBuffer -> ByteArray(size).also { val pos = data.position(); data.get(it); data.position(pos) }
|
is ByteBuffer -> {
|
||||||
is ByteArray -> data.copyOfRange(param.argNullable(1) ?: 0, (param.argNullable<Int>(1) ?: 0) + size)
|
val requestedSize = param.argNullable<Int>(1) ?: data.remaining()
|
||||||
is ShortArray -> ByteArray(size * 2).also {
|
val safeSize = requestedSize.coerceAtMost(data.remaining()).coerceAtLeast(0)
|
||||||
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, param.argNullable(1) ?: 0, size)
|
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
|
else -> return@hook
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,84 @@
|
|||||||
package me.eternal.purrfectsnap.core.features.impl.experiments
|
package me.eternal.purrfectsnap.core.features.impl.experiments
|
||||||
|
|
||||||
|
import android.graphics.Typeface
|
||||||
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
|
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
|
||||||
import me.eternal.purrfectsnap.core.ModContext
|
import me.eternal.purrfectsnap.core.ModContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
private var cachedFontPath: String? = null
|
||||||
private var cacheFontPath: String? = null
|
private var cachedFontName: String? = null
|
||||||
private var cacheFontName: String? = null
|
|
||||||
|
|
||||||
fun getCustomEmojiFontPath(
|
fun getCustomEmojiFontPath(
|
||||||
context: ModContext
|
context: ModContext
|
||||||
): String? {
|
): String? {
|
||||||
val customFileName = context.config.experimental.nativeHooks.customEmojiFont.getNullable()?.takeIf { it.isNotBlank() } ?: return null
|
val customFileName = context.config.experimental.nativeHooks.customEmojiFont.getNullable()
|
||||||
val safeFileName = customFileName.substringAfterLast("/")
|
?.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 {
|
return runCatching {
|
||||||
val handle = context.fileHandlerManager.getFileHandle(
|
val handle = context.fileHandlerManager.getFileHandle(
|
||||||
FileHandleScope.USER_IMPORT.key,
|
FileHandleScope.USER_IMPORT.key,
|
||||||
customFileName
|
customFileName
|
||||||
) ?: return@runCatching null
|
) ?: return@runCatching clearCachedPath()
|
||||||
if (!handle.exists()) {
|
if (!handle.exists()) {
|
||||||
File(context.androidContext.filesDir, "emoji_fonts")
|
val oldCacheFile = File(context.androidContext.cacheDir, "emoji_fonts").resolve(safeFileName)
|
||||||
.resolve(safeFileName)
|
return@runCatching clearCachedPath(oldCacheFile)
|
||||||
.takeIf { it.exists() }
|
|
||||||
?.delete()
|
|
||||||
cacheFontPath = ""
|
|
||||||
cacheFontName = safeFileName
|
|
||||||
return@runCatching null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val persistentDir = File(context.androidContext.filesDir, "emoji_fonts").apply {
|
val persistentDir = File(context.androidContext.cacheDir, "emoji_fonts").apply {
|
||||||
mkdirs()
|
mkdirs()
|
||||||
}
|
}
|
||||||
val persistentFile = File(persistentDir, safeFileName)
|
val persistentFile = File(persistentDir, safeFileName)
|
||||||
handle.open(android.os.ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
|
|
||||||
if (!persistentFile.exists() || pfd.statSize != persistentFile.length()) {
|
val needsCopy = handle.open(android.os.ParcelFileDescriptor.MODE_READ_ONLY)?.use { pfd ->
|
||||||
FileOutputStream(persistentFile).use { output ->
|
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 ->
|
android.os.ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
|
||||||
input.copyTo(output)
|
input.copyTo(output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!tempFile.renameTo(persistentFile)) {
|
||||||
|
tempFile.copyTo(persistentFile, overwrite = true)
|
||||||
|
tempFile.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cacheFontName = safeFileName
|
|
||||||
cacheFontPath = persistentFile.absolutePath
|
if (!validateFontFile(persistentFile)) {
|
||||||
cacheFontPath?.takeIf { it.isNotEmpty() }
|
return@runCatching clearCachedPath(persistentFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedFontName = safeFileName
|
||||||
|
cachedFontPath = persistentFile.absolutePath
|
||||||
|
cachedFontPath?.takeIf { it.isNotEmpty() }
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
context.log.error("Failed to get custom emoji font", it)
|
context.log.error("Failed to get custom emoji font", it)
|
||||||
|
clearCachedPath()
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ package me.eternal.purrfectsnap.core.messaging
|
|||||||
|
|
||||||
import me.eternal.purrfectsnap.common.data.ContentType
|
import me.eternal.purrfectsnap.common.data.ContentType
|
||||||
|
|
||||||
|
enum class ExportSortOrder {
|
||||||
|
NEWEST_TO_OLDEST,
|
||||||
|
OLDEST_TO_NEWEST
|
||||||
|
}
|
||||||
|
|
||||||
class ExportParams(
|
class ExportParams(
|
||||||
val exportFormat: ExportFormat = ExportFormat.HTML,
|
val exportFormat: ExportFormat = ExportFormat.HTML,
|
||||||
|
val sortOrder: ExportSortOrder = ExportSortOrder.NEWEST_TO_OLDEST,
|
||||||
val messageTypeFilter: List<ContentType>? = null,
|
val messageTypeFilter: List<ContentType>? = null,
|
||||||
val amountOfMessages: Int? = null,
|
val amountOfMessages: Int? = null,
|
||||||
val downloadMedias: Boolean = false,
|
val downloadMedias: Boolean = false,
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ object LSPatchUpdater {
|
|||||||
var HAS_LSPATCH = false
|
var HAS_LSPATCH = false
|
||||||
private set
|
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 {
|
private fun getModuleUniqueHash(module: ZipFile): String {
|
||||||
return module.entries().asSequence()
|
return module.entries().asSequence()
|
||||||
.filter { !it.isDirectory }
|
.filter { !it.isDirectory }
|
||||||
@@ -20,6 +30,8 @@ object LSPatchUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onBridgeConnected(context: ModContext) {
|
fun onBridgeConnected(context: ModContext) {
|
||||||
|
ensureTranslationsLoaded(context)
|
||||||
|
|
||||||
val obfuscatedModulePath by lazy {
|
val obfuscatedModulePath by lazy {
|
||||||
(runCatching {
|
(runCatching {
|
||||||
context::class.java.classLoader?.loadClass("org.lsposed.lspatch.share.Constants")
|
context::class.java.classLoader?.loadClass("org.lsposed.lspatch.share.Constants")
|
||||||
@@ -59,19 +71,19 @@ object LSPatchUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.log.verbose("updating", TAG)
|
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
|
// copy embedded module to cache
|
||||||
runCatching {
|
runCatching {
|
||||||
seAppApk.copyTo(embeddedModule, overwrite = true)
|
seAppApk.copyTo(embeddedModule, overwrite = true)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
seAppApk.delete()
|
seAppApk.delete()
|
||||||
context.log.error("Failed to copy embedded module", it, TAG)
|
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()
|
context.forceCloseApp()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
context.longToast(context.translation["toast_purrfectsnap_updated"])
|
context.longToast(context.translation.getOrNull("toast_purrfectsnap_updated") ?: "PurrfectSnap updated!")
|
||||||
context.log.verbose("updated", TAG)
|
context.log.verbose("updated", TAG)
|
||||||
context.softRestartApp()
|
context.softRestartApp()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{ffi::CStr, fs};
|
use std::{ffi::{CStr, CString}, fs};
|
||||||
|
|
||||||
use nix::libc::{self, c_uint};
|
use nix::libc::{self, c_uint};
|
||||||
|
|
||||||
@@ -12,7 +12,18 @@ def_hook!(
|
|||||||
if pathname == "/system/fonts/NotoColorEmoji.ttf" {
|
if pathname == "/system/fonts/NotoColorEmoji.ttf" {
|
||||||
if let Some(font_path) = config::native_config().custom_emoji_font_path {
|
if let Some(font_path) = config::native_config().custom_emoji_font_path {
|
||||||
if fs::metadata(&font_path).is_ok() {
|
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 {
|
} else {
|
||||||
warn!("custom emoji font path does not exist: {}", font_path);
|
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);
|
dobby_hook_sym!("libc.so", "open", open_hook);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user