28 Commits

Author SHA1 Message Date
ΞTΞRNAL
33664c1112 v1.6.0 2026-03-29 00:40:08 +05:30
ΞTΞRNAL
bb8e644b4c fix(PR): Select highest quality media by comparing resolution by Issac XT
Fix: Select highest quality media by comparing resolution
2026-03-28 23:53:04 +05:30
ΞTΞRNAL
21fba253b1 v1.5.9 2026-03-28 12:57:15 +05:30
Issac
9e5b318120 Fix: Select highest quality media by comparing resolution instead of assuming array order 2026-03-28 11:01:39 +05:30
ΞTΞRNAL
00c5d60b7b v1.5.8 2026-03-28 02:42:33 +05:30
ΞTΞRNAL
21cd306132 fix(PR): QOL fixes & Optimizations by Kaladin
QOL fixes & Optimizations.
2026-03-28 01:40:24 +05:30
DarkKnight2122
0d42aed0ff feat: Auto Open optimization 2026-03-28 00:22:36 +05:30
DarkKnight2122
f8fdd1893f feat: Task page layout revamp 2026-03-28 00:22:36 +05:30
DarkKnight2122
a8e2148b26 feat: Camera, Audio, Video & Network Optimizations 2026-03-28 00:22:35 +05:30
ΞTΞRNAL
1e9ad8eb2b Add v1.5.6 changelog entry 2026-03-27 01:06:17 +05:30
ΞTΞRNAL
182e7eefeb v1.5.6 2026-03-27 01:05:17 +05:30
ΞTΞRNAL
070a8ffaf7 v1.5.5 2026-03-26 21:24:28 +05:30
ΞTΞRNAL
95f98221e3 v1.5.4 2026-03-26 12:13:52 +05:30
ΞTΞRNAL
dbbc52b67f v1.5.3 2026-03-25 21:59:31 +05:30
ΞTΞRNAL
b7c8042a93 Merge branch 'dev' of https://github.com/particle-box/PurrfectSnap into dev 2026-03-24 03:37:30 +05:30
ΞTΞRNAL
141b5e16a0 feat: continuous snap feature! 2026-03-24 03:36:39 +05:30
ΞTΞRNAL
901e4f81b8 fix: Missing changes 2026-03-22 20:32:16 +05:30
ΞTΞRNAL
a4e86b5ab4 fix(PR): Aphelion task page layout optimization by Kaladin
Aphelion task page layout optimization
2026-03-22 20:31:25 +05:30
ΞTΞRNAL
bdc6d12739 fix: splitting issue for snaps sent through send override 2026-03-22 20:30:34 +05:30
DarkKnight2122
6c8d5297c8 Task page layout harmonization. 2026-03-22 19:43:20 +05:30
ΞTΞRNAL
7a8ad81d48 v1.5.0 2026-03-21 16:40:54 +05:30
ΞTΞRNAL
7db60d6eb1 fix: skip when marking as seen for newer snap versions 2026-03-21 11:39:59 +05:30
ΞTΞRNAL
565a8b8287 v1.4.9 2026-03-21 03:48:43 +05:30
ΞTΞRNAL
1c612ebc8e fix: force AMOLED theme for newer snap versions 2026-03-21 02:09:33 +05:30
ΞTΞRNAL
8db363a8f0 add missing changelog 2026-03-20 22:19:18 +05:30
ΞTΞRNAL
4fab5cc4ab fix(PR): Crash when using the theme button in android 11 & 12 by Kaladin
fix: Crash when using the theme button in android 11 & 12.
2026-03-20 22:18:12 +05:30
ΞTΞRNAL
a7a15702f3 v1.4.8 2026-03-20 22:16:37 +05:30
DarkKnight2122
04aaefc748 fix: optimize theme reveal fallbacks for Android 11 and 12 2026-03-20 19:22:55 +05:30
59 changed files with 4645 additions and 878 deletions

1
.gitignore vendored
View File

@@ -20,3 +20,4 @@ security/allowed_codes.local.*
valdi/node_modules/
hs_err_pid*.log
replay_pid*.log
.vs

View File

@@ -170,124 +170,22 @@ class DownloadProcessor (
}
}
private fun saveToConfiguredFolder(
configuredFolder: String,
fileName: String,
fileType: FileType,
inputFile: File,
metadata: DownloadMetadata,
pendingTask: PendingTask,
): GallerySaveResult {
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
?: throw Exception("Failed to open output folder")
val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name)
?: folder.createDirectory(name)
?: throw Exception("Failed to create output directory $name")
}
} else {
outputFolder
}
}
outputFileFolder.findFile(fileName)?.let { existingFile ->
pendingTask.updateProgress("Comparing existing media")
if (existingFile.length() != inputFile.length()) {
existingFile.delete()
} else {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
?: throw Exception("Failed to open existing media for comparison")
existingInputStream.use { currentExistingInputStream ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
var read1: Int
var read2: Int
inputFile.inputStream().use { inputStream ->
while (true) {
read1 = inputStream.read(buffer1)
read2 = currentExistingInputStream.read(buffer2)
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
existingFile.delete()
return@let
}
if (read1 == -1) break
}
private fun streamsMatch(stream1: InputStream, stream2: InputStream): Boolean {
stream1.use { s1 ->
stream2.use { s2 ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
while (true) {
val read1 = s1.read(buffer1)
val read2 = s2.read(buffer2)
if (read1 != read2) return false
if (read1 == -1) return true
for (i in 0 until read1) {
if (buffer1[i] != buffer2[i]) return false
}
}
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
}
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, fileName)
?: throw Exception("Failed to create output file $fileName")
pendingTask.updateProgress("Saving media to gallery")
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
?: throw Exception("Failed to open output stream for $fileName")
outputStream.use { currentOutputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(currentOutputStream)
}
}
return GallerySaveResult(outputFile.uri)
}
private fun buffersMatch(left: ByteArray, right: ByteArray, length: Int): Boolean {
for (index in 0 until length) {
if (left[index] != right[index]) return false
}
return true
}
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
val rawName = outputPath.substringAfterLast("/").ifBlank { "media" }
val sanitizedBase = sanitizeFileName(rawName.substringBeforeLast(".", rawName))
val currentExtension = rawName.substringAfterLast(".", "").lowercase()
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
val extension = if (currentExtension == targetExtension) {
currentExtension
} else {
targetExtension
}
return "$sanitizedBase.$extension"
}
private fun sanitizeFileName(name: String): String {
return name
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.replace(Regex("\\p{Cntrl}"), "")
.replace(Regex("\\s+"), " ")
.trim()
.trim('.')
.ifBlank { "media" }
}
private fun sanitizeRelativePath(path: String): String {
return path.split("/")
.mapNotNull { segment ->
segment.trim()
.takeIf { it.isNotBlank() }
?.let(::sanitizeFileName)
}
.joinToString("/")
}
private fun appendNameSuffix(fileName: String, index: Int): String {
val extension = fileName.substringAfterLast('.', "")
val baseName = fileName.substringBeforeLast(".", fileName)
return if (extension.isBlank()) {
"$baseName ($index)"
} else {
"$baseName ($index).$extension"
}
}
private fun findExistingMediaUri(collection: Uri, fileName: String, relativePath: String): Uri? {
@@ -312,29 +210,103 @@ class DownloadProcessor (
return streamsMatch(existingInputStream, inputFile.inputStream())
}
private fun filesMatch(existingFile: File, inputFile: File): Boolean {
return streamsMatch(existingFile.inputStream(), inputFile.inputStream())
private fun buildOutputFileName(outputPath: String, fileType: FileType): String {
val rawName = outputPath.trimEnd('/').substringAfterLast("/").ifBlank { "media" }
val targetExtension = fileType.fileExtension?.lowercase() ?: "dat"
val baseName = if (rawName.lowercase().endsWith(".$targetExtension")) {
rawName.substringBeforeLast(".")
} else {
rawName
}
return "${sanitizeFileName(baseName)}.$targetExtension"
}
private fun streamsMatch(existingInputStream: InputStream, inputInputStream: InputStream): Boolean {
existingInputStream.use { currentExistingInputStream ->
val buffer1 = ByteArray(1024 * 1024)
val buffer2 = ByteArray(1024 * 1024)
var read1: Int
var read2: Int
private fun sanitizeFileName(name: String): String {
return name
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.replace(Regex("\\p{Cntrl}"), "")
.replace(Regex("\\s+"), " ")
.trim()
.trim('.')
.ifBlank { "media" }
}
inputInputStream.use { inputStream ->
while (true) {
read1 = inputStream.read(buffer1)
read2 = currentExistingInputStream.read(buffer2)
if (read1 != read2 || (read1 > 0 && !buffersMatch(buffer1, buffer2, read1))) {
return false
}
if (read1 == -1) break
private fun sanitizeRelativePath(path: String): String {
return path.trimEnd('/').split("/")
.mapNotNull { segment ->
segment.trim()
.takeIf { it.isNotBlank() }
?.let(::sanitizeFileName)
}
.joinToString("/")
}
private fun appendNameSuffix(fileName: String, index: Int): String {
val extension = fileName.substringAfterLast('.', "")
val baseName = fileName.substringBeforeLast(".", fileName)
return if (extension.isBlank()) {
"$baseName ($index)"
} else {
"$baseName ($index).$extension"
}
}
private fun saveToConfiguredFolder(
configuredFolder: String,
fileName: String,
fileType: FileType,
inputFile: File,
metadata: DownloadMetadata,
pendingTask: PendingTask,
): GallerySaveResult {
val outputFolder = DocumentFile.fromTreeUri(remoteSideContext.androidContext, Uri.parse(configuredFolder))
?: throw Exception("Failed to open output folder")
val outputFileFolder = metadata.outputPath.let {
if (it.contains("/")) {
it.substringBeforeLast("/").split("/").fold(outputFolder) { folder, name ->
folder.findFile(name)
?: folder.createDirectory(name)
?: throw Exception("Failed to create output directory $name")
}
} else {
outputFolder
}
}
return true
var finalFileName = fileName
var collisionCount = 0
while (true) {
val existingFile = outputFileFolder.findFile(finalFileName) ?: break
if (existingFile.length() == inputFile.length()) {
val existingInputStream = remoteSideContext.androidContext.contentResolver.openInputStream(existingFile.uri)
if (existingInputStream != null && streamsMatch(existingInputStream, inputFile.inputStream())) {
return GallerySaveResult(existingFile.uri, alreadyDownloaded = true)
}
}
collisionCount++
finalFileName = appendNameSuffix(fileName, collisionCount)
}
val outputFile = outputFileFolder.createFile(fileType.mimeType, finalFileName)
?: throw Exception("Failed to create output file $finalFileName")
pendingTask.updateProgress("Saving media to gallery")
val outputStream = remoteSideContext.androidContext.contentResolver.openOutputStream(outputFile.uri)
?: throw Exception("Failed to open output stream for $finalFileName")
outputStream.use { currentOutputStream ->
inputFile.inputStream().use { inputStream ->
inputStream.copyTo(currentOutputStream)
}
}
return GallerySaveResult(outputFile.uri)
}
private fun saveToSystemDefault(
@@ -346,7 +318,6 @@ class DownloadProcessor (
val subPath = sanitizeRelativePath(
metadata.outputPath.substringBeforeLast("/", missingDelimiterValue = "")
.replace("\\", "/")
.trim('/')
)
val baseRelative = when {
fileType.isImage -> Environment.DIRECTORY_PICTURES
@@ -356,86 +327,65 @@ class DownloadProcessor (
val relativePath = listOfNotNull(baseRelative, "PurrfectSnap", subPath.takeIf { it.isNotBlank() })
.joinToString("/") + "/"
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val collection = when {
fileType.isImage -> MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
fileType.isVideo -> MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
else -> MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
val resolver = remoteSideContext.androidContext.contentResolver
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
val extension = fileName.substringAfterLast('.', "")
if (extension.isBlank()) baseName else "$baseName.$extension"
}
findExistingMediaUri(collection, sanitizedFileName, relativePath)?.let { existingUri ->
if (contentMatches(existingUri, inputFile)) {
remoteSideContext.log.verbose("Media already exists in gallery: $sanitizedFileName")
return GallerySaveResult(existingUri, alreadyDownloaded = true)
}
}
for (attempt in 0..100) {
val candidateName = if (attempt == 0) sanitizedFileName else appendNameSuffix(sanitizedFileName, attempt)
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val candidateName = if (attempt == 0) fileName else appendNameSuffix(fileName, attempt)
findExistingMediaUri(collection, candidateName, relativePath)?.let { existingUri ->
if (contentMatches(existingUri, inputFile)) {
return GallerySaveResult(existingUri, alreadyDownloaded = true)
}
return@let
} ?: run {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, candidateName)
put(MediaStore.MediaColumns.MIME_TYPE, fileType.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = runCatching {
resolver.insert(collection, values)
}.onFailure {
remoteSideContext.log.verbose("MediaStore insert rejected $candidateName in $relativePath: ${it.message}")
}.getOrNull() ?: continue
val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: return@run
runCatching {
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream for $candidateName")
runCatching {
resolver.openOutputStream(uri)?.use { out ->
inputFile.inputStream().use { it.copyTo(out) }
} ?: throw IllegalStateException("Failed to open output stream")
ContentValues().apply {
put(MediaStore.MediaColumns.IS_PENDING, 0)
}.also { resolver.update(uri, it, null, null) }
return GallerySaveResult(uri)
}.onFailure {
runCatching { resolver.delete(uri, null, null) }
remoteSideContext.log.error("Failed writing media to gallery for $candidateName", it)
ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }.also { resolver.update(uri, it, null, null) }
return GallerySaveResult(uri)
}.onFailure {
runCatching { resolver.delete(uri, null, null) }
}
}
}
throw IllegalStateException("Failed to allocate a unique gallery file for $sanitizedFileName in $relativePath")
throw IllegalStateException("Failed to allocate unique filename")
} else {
@Suppress("DEPRECATION")
val baseDir = Environment.getExternalStoragePublicDirectory(baseRelative)
val destDir = File(baseDir, "PurrfectSnap" + (if (subPath.isNotBlank()) "/$subPath" else ""))
destDir.mkdirs()
val sanitizedFileName = sanitizeFileName(fileName.substringBeforeLast(".", fileName)).let { baseName ->
val extension = fileName.substringAfterLast('.', "")
if (extension.isBlank()) baseName else "$baseName.$extension"
}
var destFile = File(destDir, sanitizedFileName)
if (destFile.exists()) {
if (destFile.length() == inputFile.length() && filesMatch(destFile, inputFile)) {
var destFile = File(destDir, fileName)
var suffix = 1
while (destFile.exists()) {
if (destFile.length() == inputFile.length() && streamsMatch(destFile.inputStream(), inputFile.inputStream())) {
return GallerySaveResult(Uri.fromFile(destFile), alreadyDownloaded = true)
}
var suffix = 1
while (destFile.exists()) {
destFile = File(destDir, appendNameSuffix(sanitizedFileName, suffix++))
}
}
FileOutputStream(destFile).use { out ->
inputFile.inputStream().use { it.copyTo(out) }
destFile = File(destDir, appendNameSuffix(fileName, suffix++))
}
FileOutputStream(destFile).use { out -> inputFile.inputStream().use { it.copyTo(out) } }
runCatching {
remoteSideContext.androidContext.sendBroadcast(
Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
data = Uri.fromFile(destFile)
}
)
remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { data = Uri.fromFile(destFile) })
}
GallerySaveResult(Uri.fromFile(destFile))
return GallerySaveResult(Uri.fromFile(destFile))
}
}

View File

@@ -46,7 +46,8 @@ data class Task(
val type: TaskType,
val title: String,
val author: String?,
val hash: String
val hash: String,
val isAutoOpen: Boolean = false
) {
var changeListener: () -> Unit = {}

View File

@@ -10,11 +10,13 @@ class RemoteTaskInterface(
private val activeTasks = context.taskManager.getActiveTasks()
override fun createTask(type: String, title: String, author: String, hash: String): String {
val taskType = TaskType.fromKey(type)
val task = Task(
type = TaskType.fromKey(type),
type = taskType,
title = title,
author = author.takeIf { it.isNotBlank() },
hash = hash
hash = hash,
isAutoOpen = taskType == TaskType.CHAT_ACTION
)
context.taskManager.createPendingTask(task)
return hash

View File

@@ -38,11 +38,13 @@ class TaskManager(
private val activeTasks = mutableMapOf<Long, PendingTask>()
private fun readTaskFromCursor(cursor: android.database.Cursor): Task {
val taskType = TaskType.fromKey(cursor.getStringOrNull("type")!!)
val task = Task(
type = TaskType.fromKey(cursor.getStringOrNull("type")!!),
type = taskType,
title = cursor.getStringOrNull("title")!!,
author = cursor.getStringOrNull("author"),
hash = cursor.getStringOrNull("hash")!!
hash = cursor.getStringOrNull("hash")!!,
isAutoOpen = taskType == TaskType.CHAT_ACTION
)
task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!)
task.extra = cursor.getStringOrNull("extra")

View File

@@ -214,13 +214,20 @@ class MainActivity : ComponentActivity() {
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
navigation.NavContent(contentPadding, startDestination)
// Theme Reveal Overlay
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
// Theme Reveal Overlay (Android 13+ only for stability)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
navigation.themeRevealState.pendingReveal?.let { revealRequest ->
CircularRevealOverlay(
context = managerContext,
request = revealRequest,
onComplete = { navigation.themeRevealState.clearReveal() }
)
}
} else {
// Instantly clear reveal state on older versions
navigation.themeRevealState.pendingReveal?.let {
navigation.themeRevealState.clearReveal()
}
}
Box(

View File

@@ -71,6 +71,11 @@ import me.eternal.purrfectsnap.ui.util.*
import java.io.File
class TasksRootSection : Routes.Route() {
enum class TaskTab {
ACTIVE, SCHEDULED
}
internal var selectedTab by mutableStateOf(TaskTab.ACTIVE)
internal var activeTasks by mutableStateOf(listOf<PendingTask>())
internal var recentTasks = mutableStateListOf<Task>()
internal val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>()
@@ -201,15 +206,94 @@ class TasksRootSection : Routes.Route() {
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item(key = "auto_open_card") {
var queueItems by remember { mutableStateOf(listOf<Any>()) }
var processedCount by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
runCatching {
val autoOpen = context.bridgeService?.messagingBridge?.getAutoOpenInterface()
processedCount = autoOpen?.processedCount ?: 0
val items = autoOpen?.queueItems ?: emptyList()
queueItems = items.mapNotNull {
runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull()
}
}
kotlinx.coroutines.delay(2000)
}
}
if (queueItems.isNotEmpty() || processedCount > 0) {
Surface(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
shape = MaterialTheme.shapes.large,
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
IconButton(onClick = {
runCatching { context.bridgeService?.messagingBridge?.getAutoOpenInterface()?.reset() }
}) {
Icon(Icons.Default.Refresh, null, modifier = Modifier.size(20.dp))
}
}
Text(
"${translation["auto_open_snaps.queue_size"] ?: "Queue"}: ${queueItems.size} \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f)
)
}
}
}
}
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
item {
AphelionTasksEmptyState(translation["no_tasks"])
}
}
items(activeTasks, key = { it.task.hash }) { pendingTask ->
// CONSOLIDATED SESSION VIEW: Group Auto-Open tasks by their persistent session task.
// Non-AutoOpen tasks (Downloads, etc.) remain as individual cards.
val groupedActiveTasks = activeTasks.distinctBy { it.task.hash }
items(groupedActiveTasks, key = { it.task.hash }) { pendingTask ->
val isAutoOpen = pendingTask.task.isAutoOpen
val pulseAnimation = rememberInfiniteTransition(label = "pulse")
val pulseAlpha by pulseAnimation.animateFloat(
initialValue = 0.15f,
targetValue = 0.45f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
TaskCard(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.let {
if (isAutoOpen) {
it.border(
width = 1.5.dp,
brush = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha),
PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha)
)
),
shape = MaterialTheme.shapes.large
)
} else it
},
task = pendingTask.task,
pendingTask = pendingTask
)
@@ -803,7 +887,13 @@ class TasksRootSection : Routes.Route() {
if (isActive) {
taskProgressLabel?.let {
Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White)
val labelText = if (task.isAutoOpen) {
// Live Metrics: Show Snaps/min and session total
val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0 // Placeholder for session start
val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0"
"$it$speed snaps/min"
} else it
Text(labelText, style = MaterialTheme.typography.labelSmall, color = Color.White)
}
if (taskProgress != -1) {
LinearProgressIndicator(
@@ -842,4 +932,186 @@ class TasksRootSection : Routes.Route() {
}
}
}
@Composable
internal fun TaskTabSwitcher(
selectedTab: TaskTab,
onTabSelected: (TaskTab) -> Unit,
activeCount: Int,
scheduledCount: Int
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
TaskTab.entries.forEach { tab ->
val selected = selectedTab == tab
val count = if (tab == TaskTab.ACTIVE) activeCount else scheduledCount
Surface(
shape = RoundedCornerShape(18.dp),
color = if (selected) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.06f),
border = if (selected) BorderStroke(1.dp, Brush.linearGradient(listOf(PurrfectPalette.glowPrimary, PurrfectPalette.glowSecondary))) else BorderStroke(
1.dp,
Color.White.copy(alpha = 0.12f)
),
modifier = Modifier.weight(1f)
) {
Row(
modifier = Modifier
.clickable { onTabSelected(tab) }
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (tab == TaskTab.ACTIVE) Icons.Filled.Timer else Icons.Filled.Schedule,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = if (tab == TaskTab.ACTIVE) (context.translation["tasks_tab_active"] ?: "Active") else (context.translation["tasks_tab_scheduled"] ?: "Scheduled"),
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
fontSize = 13.sp
)
}
}
}
}
}
@Composable
internal fun TasksHeader(
selectedTab: TaskTab,
onTabSelected: (TaskTab) -> Unit,
activeCount: Int,
scheduledCount: Int,
runningCount: Int,
subtitle: String,
onClear: () -> Unit,
onMerge: () -> Unit,
canMerge: Boolean
) {
val haptic = LocalHapticFeedback.current
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp)
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()),
shape = RoundedCornerShape(26.dp),
color = Color.White.copy(alpha = 0.07f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = context.translation["manager.routes.tasks"],
color = Color.White,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp
)
Text(
text = subtitle,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(
modifier = Modifier.wrapContentWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (canMerge) {
Surface(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMerge()
},
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = context.translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(context.translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
}
}
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.16f))
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Filled.PlaylistAddCheckCircle,
contentDescription = null,
tint = Color.White
)
Text(
text = (context.translation["tasks_running_count"] ?: "{count} running")
.replace("{count}", runningCount.toString()),
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp
)
}
}
IconButton(onClick = onClear) {
Icon(
imageVector = Icons.Filled.DeleteSweep,
contentDescription = context.translation["tasks_clear_button_description"],
tint = Color.White
)
}
}
}
TaskTabSwitcher(
selectedTab = selectedTab,
onTabSelected = onTabSelected,
activeCount = activeCount,
scheduledCount = scheduledCount
)
}
}
}
}

View File

@@ -53,8 +53,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
@@ -78,6 +76,8 @@ import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.*
import me.eternal.purrfectsnap.ui.util.Dialog
import me.eternal.purrfectsnap.ui.util.DialogProperties
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.max

View File

@@ -126,7 +126,7 @@ class BetterLocationRoot : Routes.Route() {
overflow = TextOverflow.Ellipsis
)
Text(
text = context.translation.format(
text = translation.format(
"spoofed_coordinates_title",
"latitude" to friendLocation.latitude.toFloat().toString(),
"longitude" to friendLocation.longitude.toFloat().toString()

View File

@@ -466,6 +466,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
var changelogLoading by remember { mutableStateOf(false) }
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showFullChangelogDialog by rememberSaveable { mutableStateOf(false) }
var fullChangelogText by rememberSaveable { mutableStateOf<String?>(null) }
var fullChangelogLoading by remember { mutableStateOf(false) }
var fullChangelogError by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by rememberSaveable { mutableStateOf(false) }
var announcementsText by rememberSaveable { mutableStateOf<String?>(null) }
var announcementsLoading by remember { mutableStateOf(false) }
@@ -531,6 +535,31 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
}
}
fun loadFullChangelog() {
if (fullChangelogText != null) return
fullChangelogLoading = true
fullChangelogError = null
coroutineScope.launch(Dispatchers.IO) {
val url = if (updateChannel == "prerelease") changelogPrereleaseUrl else changelogStableUrl
runCatching {
OkHttpClient().newCall(Request.Builder().url(url).build()).execute().use { response ->
val body = response.body?.string() ?: throw IllegalStateException("Empty body")
body.trim()
}
}.onSuccess { text ->
withContext(Dispatchers.Main) {
fullChangelogText = text
fullChangelogLoading = false
}
}.onFailure { e ->
withContext(Dispatchers.Main) {
fullChangelogError = e.message ?: "Failed to fetch"
fullChangelogLoading = false
}
}
}
}
val borderPath = remember { Path() }
val uPath = remember { Path() }
@@ -626,7 +655,8 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() },
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
AphelionTopBarActionChip(
icon = Icons.Filled.Notifications, label = null,
@@ -634,6 +664,12 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
contentDescription = translation["announcements_button_description"],
haptic = haptic
) { showAnnouncementsDialog = true; loadAnnouncements() }
AphelionTopBarActionChip(
icon = Icons.Filled.Description, label = null,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
haptic = haptic
) { showFullChangelogDialog = true; loadFullChangelog() }
}
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
Row(
@@ -778,6 +814,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
text = "", icon = Icons.Filled.Notifications,
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
onConfirm = { showAnnouncementsDialog = false },
showCloseButton = false,
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
@@ -796,6 +833,7 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
onConfirm = { haptic.performHapticFeedback(HapticFeedbackType.LongPress); showChangelogDialog = false; handleUpdateAction() },
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
onDismiss = { showChangelogDialog = false },
showCloseButton = false,
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (changelogLoading) CircularProgressIndicator(color = Color.White)
@@ -806,6 +844,28 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
)
}
if (showFullChangelogDialog) {
AestheticDialog(
onDismissRequest = { showFullChangelogDialog = false },
title = translation["changelog_dialog_title"] ?: "Changelog",
text = "",
icon = Icons.Filled.Description,
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
onConfirm = { showFullChangelogDialog = false },
showCloseButton = false,
customContent = {
Column(
modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
}
}
)
}
if (showQuickActionsMenu) {
QuickActionsDialog(
quickActions = cards,

View File

@@ -58,7 +58,9 @@ import me.eternal.purrfectsnap.ui.util.OnLifecycleEvent
import me.eternal.purrfectsnap.ui.util.coil.cacheKey
import me.eternal.purrfectsnap.ui.util.scaleOnPress
import me.eternal.purrfectsnap.ui.util.Motion
import me.eternal.purrfectsnap.ui.manager.pages.TasksRootSection.TaskTab
import me.eternal.purrfectsnap.ui.util.headerHeightTracker
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -97,6 +99,13 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
.fillMaxSize()
.background(PurrfectPalette.backgroundGradient)
) {
val scrollOffset = routes.navigation?.globalScrollOffset ?: 0
val focusFactor = (scrollOffset.toFloat() / Motion.HEADER_MORPH_THRESHOLD).coerceIn(0f, 1f)
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val containerTopPadding = androidx.compose.ui.unit.lerp(statusBarHeight + 2.dp, 0.dp, focusFactor)
val topCorners = androidx.compose.ui.unit.lerp(28.dp, 0.dp, focusFactor)
val subtitle = if (activeTasks.isNotEmpty()) {
translation.format(
"summary_active",
@@ -110,44 +119,237 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
)
}
// The "Structured Glass" Container (1:1 with build 33a7e8f)
Surface(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
.padding(top = 12.dp),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp, bottomStart = 0.dp, bottomEnd = 0.dp),
.padding(top = containerTopPadding),
shape = RoundedCornerShape(topStart = topCorners, topEnd = topCorners, bottomStart = 0.dp, bottomEnd = 0.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 10.dp,
end = 10.dp,
top = controlsHeight,
bottom = routes.bottomPadding + 20.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
if (activeTasks.isEmpty() && recentTasks.isEmpty()) {
AphelionTasksEmptyState(text = translation["no_tasks"] ?: "No tasks")
Column(modifier = Modifier.fillMaxSize().padding(top = controlsHeight - 44.dp)) {
Surface(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(14.dp),
color = Color.White.copy(alpha = 0.05f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.08f))
) {
Row(
modifier = Modifier.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
TaskTab.entries.forEach { tab ->
val isSelected = selectedTab == tab
val backgroundAlpha by animateFloatAsState(if (isSelected) 0.12f else 0f, label = "tabBg")
Box(
modifier = Modifier
.weight(1f)
.height(38.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.White.copy(alpha = backgroundAlpha))
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
selectedTab = tab
},
contentAlignment = Alignment.Center
) {
Text(
text = if (tab == TaskTab.ACTIVE) (translation["tasks_tab_active"] ?: "Active") else (translation["tasks_tab_scheduled"] ?: "Scheduled"),
color = if (isSelected) Color.White else Color.White.copy(alpha = 0.5f),
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
fontSize = 13.sp
)
}
}
}
}
items(activeTasks, key = { it.taskId }) { pendingTask ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
val activeList = activeTasks.filter { it.task.type != TaskType.SCHEDULED_SEND }
val recentList = recentTasks.filter { task ->
task.type != TaskType.SCHEDULED_SEND && activeList.none { it.task.hash == task.hash }
}
items(recentTasks, key = { it.hash }) { task ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
val scheduledActive = activeTasks.filter { it.task.type == TaskType.SCHEDULED_SEND }
val scheduledRecent = recentTasks.filter { task ->
task.type == TaskType.SCHEDULED_SEND && scheduledActive.none { it.task.hash == task.hash }
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
LazyColumn(
state = scrollState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 10.dp,
end = 10.dp,
top = 8.dp,
bottom = routes.bottomPadding + 20.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (selectedTab == TaskTab.ACTIVE) {
item(key = "auto_open_card") {
var queueItems by remember { mutableStateOf(listOf<Any>()) }
var processedCount by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
runCatching {
val autoOpen = context.bridgeService?.messagingBridge?.autoOpenInterface
processedCount = autoOpen?.processedCount ?: 0
val items = autoOpen?.queueItems ?: emptyList()
queueItems = items.mapNotNull {
runCatching { context.gson.fromJson(it, Map::class.java) }.getOrNull()
}
}
delay(2000)
}
}
val queueSize = queueItems.size
if (queueSize > 0 || processedCount > 0) {
var isExpanded by remember { mutableStateOf(false) }
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
isExpanded = !isExpanded
}
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.AutoFixHigh, null, tint = PurrfectPalette.glowSecondary, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(10.dp))
Text(translation["auto_open_snaps.title"] ?: "Auto Open Snaps", fontWeight = FontWeight.Bold, color = Color.White)
}
Icon(
if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
null,
tint = Color.White.copy(alpha = 0.5f)
)
}
Row(modifier = Modifier.padding(top = 4.dp, start = 30.dp)) {
Text(
"${translation["auto_open_snaps.queue_size"] ?: "Queue"}: $queueSize \u00b7 ${translation["auto_open_snaps.processed_count"] ?: "Opened"}: $processedCount",
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.6f)
)
}
AnimatedVisibility(
visible = isExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
Column(modifier = Modifier.padding(top = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
queueItems.forEach { rawItem ->
val item = rawItem as? Map<String, String> ?: return@forEach
Row(
modifier = Modifier.fillMaxWidth().background(Color.White.copy(alpha = 0.03f), RoundedCornerShape(8.dp)).padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(item["senderInfo"] ?: "", fontSize = 13.sp, color = Color.White, fontWeight = FontWeight.Medium)
Text(item["contentType"] ?: "", fontSize = 11.sp, color = Color.White.copy(alpha = 0.5f))
}
Text(item["conversationType"] ?: "", fontSize = 10.sp, color = PurrfectPalette.glowSecondary.copy(alpha = 0.7f))
}
}
if (processedCount > 0 || queueSize > 0) {
OutlinedButton(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
runCatching { context.bridgeService?.messagingBridge?.autoOpenInterface?.reset() }
},
modifier = Modifier.fillMaxWidth().height(36.dp),
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, Color.Red.copy(alpha = 0.3f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Red.copy(alpha = 0.7f))
) {
Text(translation["auto_open_snaps.action_reset"] ?: "Reset Statistics", fontSize = 12.sp)
}
}
}
}
}
}
}
}
if (activeList.isEmpty() && recentList.isEmpty()) {
item(key = "active_empty") { AphelionTasksEmptyState(text = translation["tasks_no_active_tasks"] ?: "No active tasks") }
}
val groupedActiveTasks = activeList.distinctBy { it.task.hash }
items(groupedActiveTasks, key = { it.task.hash }) { pendingTask ->
val isAutoOpenTask = pendingTask.task.isAutoOpen
val pulseAnimation = rememberInfiniteTransition(label = "pulse")
val pulseAlpha by pulseAnimation.animateFloat(
initialValue = 0.15f,
targetValue = 0.45f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
AphelionTaskCard(
modifier = Modifier
.fillMaxWidth()
.let {
if (isAutoOpenTask) {
it.border(
width = 1.5.dp,
brush = Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = pulseAlpha),
PurrfectPalette.glowSecondary.copy(alpha = pulseAlpha)
)
),
shape = RoundedCornerShape(22.dp)
)
} else it
},
task = pendingTask.task,
pendingTask = pendingTask
)
}
items(recentList.filter { task -> groupedActiveTasks.none { it.task.hash == task.hash } }, key = { it.hash }) { task ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
}
} else {
if (scheduledActive.isEmpty() && scheduledRecent.isEmpty()) {
item(key = "scheduled_empty") { AphelionTasksEmptyState(text = translation["tasks_no_scheduled_tasks"] ?: "No scheduled snaps") }
}
items(scheduledActive, key = { it.taskId }) { pendingTask ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), pendingTask.task, pendingTask = pendingTask)
}
items(scheduledRecent, key = { it.hash }) { task ->
AphelionTaskCard(modifier = Modifier.fillMaxWidth(), task)
}
}
item {
Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks()
}
}
}
}
@@ -160,6 +362,35 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
enableMorph = true,
modifier = Modifier.headerHeightTracker { controlsHeight = it },
actions = {
if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true }
}
if (canMergeSelection) {
Surface(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(taskSelection.toList().also {
taskSelection.clear()
}.map { it.first to it.second!! })
},
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["tasks_merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["tasks_merge_button"] ?: "Merge", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
}
}
}
Surface(
shape = RoundedCornerShape(18.dp),
color = Color.White.copy(alpha = 0.08f),
@@ -184,34 +415,12 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
)
}
}
if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) {
Surface(
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
mergeSelection(
taskSelection.toList().also { taskSelection.clear() }
.map { it.first to it.second!! }
)
},
shape = RoundedCornerShape(18.dp),
color = PurrfectPalette.glowPrimary.copy(alpha = 0.2f),
border = BorderStroke(1.dp, PurrfectPalette.glowPrimary.copy(alpha = 0.4f))
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(Icons.Filled.Merge, contentDescription = translation["merge_button"], tint = Color.White, modifier = Modifier.size(16.dp))
Text(translation["merge_button"], color = Color.White, fontWeight = FontWeight.Bold, fontSize = 11.sp)
}
}
}
IconButton(onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showConfirmDialog = true
}) {
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["clear_button_description"], tint = Color.White)
Icon(Icons.Filled.DeleteSweep, contentDescription = translation["tasks_clear_button_description"], tint = Color.White)
}
}
)
@@ -220,11 +429,11 @@ fun TasksRootSection.AphelionTasksScreen(nav: NavBackStackEntry) {
if (showConfirmDialog) {
val isSelection = taskSelection.isNotEmpty()
val titleText = if (isSelection) {
translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
translation.format("tasks_remove_selected_tasks_confirm", "count" to taskSelection.size.toString())
} else {
translation["remove_all_tasks_confirm"]
translation["tasks_remove_all_tasks_confirm"]
}
val messageText = if (isSelection) translation["remove_selected_tasks_title"] else translation["remove_all_tasks_title"]
val messageText = if (isSelection) translation["tasks_remove_selected_tasks_title"] else translation["tasks_remove_all_tasks_title"]
TaskDangerDialog(
visible = showConfirmDialog,
@@ -487,10 +696,19 @@ internal fun TasksRootSection.AphelionTaskCard(modifier: Modifier, task: Task, p
}
if (!taskStatus.isFinalStage()) {
if (!isActive) {
if (isActive) {
taskProgressLabel?.let {
val labelText = if (task.isAutoOpen) {
val sessionTimeMins = (System.currentTimeMillis() - 0L) / 60000.0
val speed = if (sessionTimeMins > 0.1) String.format("%.1f", 0 / sessionTimeMins) else "0.0"
"$it$speed snaps/min"
} else it
Text(labelText, style = MaterialTheme.typography.bodySmall, color = Color.White)
}
} else {
taskProgressLabel?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = Color.White) }
}
if (taskProgress != -1 && taskProgressLabel == null) {
if (taskProgress != -1 && (taskProgressLabel == null || isActive)) {
LinearProgressIndicator(
progress = { taskProgress.toFloat() / 100f },
strokeCap = StrokeCap.Round, modifier = Modifier.fillMaxWidth(),

View File

@@ -345,6 +345,10 @@ object LegacyTheme : ThemeContract {
var changelogError by remember { mutableStateOf<String?>(null) }
var changelogText by remember { mutableStateOf<String?>(null) }
var changelogVersion by remember { mutableStateOf<String?>(null) }
var showFullChangelogDialog by remember { mutableStateOf(false) }
var fullChangelogLoading by remember { mutableStateOf(false) }
var fullChangelogError by remember { mutableStateOf<String?>(null) }
var fullChangelogText by remember { mutableStateOf<String?>(null) }
var showAnnouncementsDialog by remember { mutableStateOf(false) }
var announcementsLoading by remember { mutableStateOf(false) }
var announcementsError by remember { mutableStateOf<String?>(null) }
@@ -415,6 +419,23 @@ object LegacyTheme : ThemeContract {
}
}
fun loadFullChangelog(url: String) {
if (fullChangelogText != null) return
fullChangelogLoading = true; fullChangelogError = null
coroutineScope.launch(Dispatchers.IO) {
runCatching {
changelogClient.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (!response.isSuccessful) throw IllegalStateException("Failed to fetch changelog (${response.code})")
response.body?.string()?.trim() ?: throw IllegalStateException("Empty changelog body")
}
}.onSuccess { text ->
withContext(Dispatchers.Main) { fullChangelogText = text; fullChangelogLoading = false }
}.onFailure { error ->
withContext(Dispatchers.Main) { fullChangelogError = error.message ?: "Failed to load changelog"; fullChangelogLoading = false }
}
}
}
LaunchedEffect(Unit) {
if (context.sharedPreferences.getBoolean("show_changelog_on_launch", false)) {
val version = context.sharedPreferences.getString("changelog_version_on_launch", null)
@@ -450,6 +471,9 @@ object LegacyTheme : ThemeContract {
LocalTopBarActionChip(icon = Icons.Filled.Notifications, label = null, contentDescription = translation["announcements_button_description"]) {
showAnnouncementsDialog = true; loadAnnouncements()
}
LocalTopBarActionChip(icon = Icons.Filled.Description, label = null, contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog") {
showFullChangelogDialog = true; loadFullChangelog(changelogUrl)
}
}
Row(modifier = Modifier.wrapContentWidth(Alignment.End), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
LocalHomeActionChips()
@@ -570,6 +594,7 @@ object LegacyTheme : ThemeContract {
onConfirm = { showChangelogDialog = false; handleUpdateAction() },
dismissButtonText = translation["changelog_dialog_cancel_button"] ?: "Cancel",
onDismiss = { showChangelogDialog = false },
showCloseButton = false,
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (changelogLoading) CircularProgressIndicator(color = Color.White)
@@ -587,6 +612,7 @@ object LegacyTheme : ThemeContract {
text = "", icon = Icons.Filled.Notifications,
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
onConfirm = { showAnnouncementsDialog = false },
showCloseButton = false,
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (announcementsLoading) CircularProgressIndicator(color = Color.White)
@@ -597,6 +623,24 @@ object LegacyTheme : ThemeContract {
)
}
if (showFullChangelogDialog) {
AestheticDialog(
onDismissRequest = { showFullChangelogDialog = false },
title = translation["changelog_dialog_title"] ?: "Changelog",
text = "", icon = Icons.Filled.Description,
confirmButtonText = translation["announcements_dialog_close_button"] ?: "Close",
onConfirm = { showFullChangelogDialog = false },
showCloseButton = false,
customContent = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 340.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
if (fullChangelogLoading) CircularProgressIndicator(color = Color.White)
else if (fullChangelogError != null) Text(fullChangelogError!!, color = Color.Red, fontSize = 14.sp)
else Text(fullChangelogText ?: translation["changelog_dialog_empty"] ?: "", color = PurrfectPalette.textPrimary, fontSize = 14.sp)
}
}
)
}
if (showQuickActionsMenu) {
QuickActionsDialog(
quickActions = cards,

View File

@@ -7,11 +7,7 @@ import androidx.annotation.RequiresApi
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
@@ -24,14 +20,12 @@ import me.eternal.purrfectsnap.RemoteSideContext
private const val REVEAL_DURATION_MS = 3200
private const val WAVE_BAND_WIDTH_PX = 300f
private const val BLUR_ZONE_PX = 120f
private const val BLUR_RADIUS = 30f
private const val FADE_ZONE_PX = 80f
// "Explosive Dissipation" Easing: Instant high velocity at start, rapid energy loss, ending in a slow crawl.
private val AphelionEasing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
@Composable
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun CircularRevealOverlay(
context: RemoteSideContext,
request: ThemeRevealRequest,
@@ -87,17 +81,17 @@ fun CircularRevealOverlay(
label = "wave_time_value"
)
// --- AGSL SHADER LOGIC (Android 13+) ---
val runtimeShader = remember(bitmap) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
} else null
android.graphics.RuntimeShader(WaveEdgeShader.AGSL).apply {
setInputShader("content", BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP))
}
}
val shaderPaint = remember(runtimeShader, bitmap) {
val shaderPaint = remember(runtimeShader) {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = runtimeShader ?: BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
shader = runtimeShader
}
}
@@ -106,118 +100,12 @@ fun CircularRevealOverlay(
val center = request.originCenter
drawIntoCanvas { canvas ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && runtimeShader != null) {
runtimeShader.setFloatUniform("revealRadius", radius)
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
runtimeShader.setFloatUniform("time", timeValue)
runtimeShader.setFloatUniform("uProgress", progress)
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
drawWithBlurReveal(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
} else {
drawWithClipFade(canvas.nativeCanvas, bitmap, radius, center.x, center.y, size.width, size.height)
}
runtimeShader.setFloatUniform("revealRadius", radius)
runtimeShader.setFloatUniform("revealCenter", center.x, center.y)
runtimeShader.setFloatUniform("bandWidth", WAVE_BAND_WIDTH_PX)
runtimeShader.setFloatUniform("time", timeValue)
runtimeShader.setFloatUniform("uProgress", progress)
canvas.nativeCanvas.drawRect(0f, 0f, size.width, size.height, shaderPaint)
}
}
}
@RequiresApi(Build.VERSION_CODES.S)
private fun drawWithBlurReveal(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerRingRadius = (radius - BLUR_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerRingRadius, android.graphics.Path.Direction.CW)
}
val renderNode = android.graphics.RenderNode("blurRing").apply {
setPosition(0, 0, canvasWidth.toInt(), canvasHeight.toInt())
setRenderEffect(android.graphics.RenderEffect.createBlurEffect(BLUR_RADIUS, BLUR_RADIUS, Shader.TileMode.CLAMP))
}
val nodeCanvas = renderNode.beginRecording()
nodeCanvas.save()
nodeCanvas.clipPath(ringPath)
nodeCanvas.drawBitmap(bitmap, 0f, 0f, null)
nodeCanvas.restore()
renderNode.endRecording()
canvas.drawRenderNode(renderNode)
val shimmerPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(50, 255, 255, 255), android.graphics.Color.TRANSPARENT),
floatArrayOf((innerRingRadius / radius).coerceIn(0f, 1f), ((radius - BLUR_ZONE_PX * 0.25f) / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, shimmerPaint)
canvas.restore()
}
private fun drawWithClipFade(
canvas: android.graphics.Canvas,
bitmap: android.graphics.Bitmap,
radius: Float,
centerX: Float,
centerY: Float,
canvasWidth: Float,
canvasHeight: Float
) {
val innerFadeRadius = (radius - FADE_ZONE_PX).coerceAtLeast(0f)
val holePath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addRect(0f, 0f, canvasWidth, canvasHeight, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
}
canvas.save()
canvas.clipPath(holePath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight,
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
}
)
canvas.restore()
val ringPath = android.graphics.Path().apply {
fillType = android.graphics.Path.FillType.EVEN_ODD
addCircle(centerX, centerY, radius, android.graphics.Path.Direction.CW)
addCircle(centerX, centerY, innerFadeRadius, android.graphics.Path.Direction.CW)
}
val fadePaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
shader = android.graphics.RadialGradient(
centerX, centerY, radius,
intArrayOf(android.graphics.Color.TRANSPARENT, android.graphics.Color.argb(80, 255, 255, 255)),
floatArrayOf((innerFadeRadius / radius).coerceIn(0f, 1f), 1f),
Shader.TileMode.CLAMP
)
}
canvas.save()
canvas.clipPath(ringPath)
canvas.drawRect(0f, 0f, canvasWidth, canvasHeight, fadePaint)
canvas.restore()
}

View File

@@ -11,21 +11,14 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -34,15 +27,11 @@ 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.text.font.FontWeight
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
import me.eternal.purrfectsnap.ui.util.AlertDialogs
@@ -55,16 +44,7 @@ class MappingsScreen : SetupScreen() {
val translation = context.translation
var infoText by remember { mutableStateOf(null as String?) }
var isGenerating by remember { mutableStateOf(false) }
var showCompletionNotice by remember { mutableStateOf(false) }
var completionCountdown by remember { mutableIntStateOf(10) }
fun finishMappings() {
if (isFirstRunFlow) {
showCompletionNotice = true
} else {
goNext()
}
}
fun finishMappings() = goNext()
if (infoText != null) {
fun dismiss() {
@@ -87,103 +67,6 @@ class MappingsScreen : SetupScreen() {
}
}
LaunchedEffect(showCompletionNotice) {
if (showCompletionNotice) {
completionCountdown = 10
while (completionCountdown > 0) {
delay(1000)
completionCountdown--
}
}
}
if (showCompletionNotice) {
val confirmLabel = if (completionCountdown > 0) {
translation.format(
"setup.mappings.confirm_understand_timeout",
"seconds" to completionCountdown.toString()
)
} else {
translation["setup.mappings.confirm_understand"]
}
AestheticDialog(
onDismissRequest = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
title = translation["setup.mappings.notice_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = { if (completionCountdown == 0) { showCompletionNotice = false; goNext() } },
confirmEnabled = completionCountdown == 0,
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Surface(
shape = RoundedCornerShape(14.dp),
color = PurrfectPalette.cardOverlayColor,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = translation["setup.mappings.notice_intro"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_1"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_2"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_step_3"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_rooted_title"],
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold,
lineHeight = 18.sp
),
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.mappings.notice_rooted_body"],
style = bodyStyle,
modifier = Modifier.fillMaxWidth()
)
}
}
}
)
}
LaunchedEffect(Unit) {
coroutineScope.launch(Dispatchers.IO) {
if (isGenerating) return@launch

View File

@@ -3,6 +3,7 @@ package me.eternal.purrfectsnap.ui.util
import android.content.Context
import android.view.MotionEvent
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
@@ -29,6 +30,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -40,7 +43,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.eternal.purrfectsnap.common.config.ConfigFlag
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog as StandardDialog
import androidx.core.net.toUri
import com.github.skydoves.colorpicker.compose.*
import com.google.gson.JsonParser
@@ -57,6 +59,10 @@ import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import okhttp3.OkHttpClient
import okhttp3.Request
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
@@ -64,9 +70,11 @@ import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Overlay
import java.io.File
import me.eternal.purrfectsnap.ui.util.purrfectSwitchColors
import me.eternal.purrfectsnap.ui.util.Dialog as StandardDialog
class AlertDialogs(
@@ -606,6 +614,13 @@ class AlertDialogs(
}
}
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
fun dismissKeyboard() {
keyboardController?.hide()
focusManager.clearFocus(force = true)
}
mapView.value = remember {
Configuration.getInstance().apply {
@@ -642,12 +657,37 @@ class AlertDialogs(
overlays.add(object: Overlay() {
override fun onSingleTapConfirmed(e: MotionEvent, mapView: MapView): Boolean {
dismissKeyboard()
marker.value?.position = mapView.projection.fromPixels(e.x.toInt(), e.y.toInt()) as GeoPoint
mapView.invalidate()
return true
}
})
overlays.add(MapEventsOverlay(object : MapEventsReceiver {
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
dismissKeyboard()
return false
}
override fun longPressHelper(p: GeoPoint?): Boolean {
dismissKeyboard()
return false
}
}))
addMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?): Boolean {
dismissKeyboard()
return false
}
override fun onZoom(event: ZoomEvent?): Boolean {
dismissKeyboard()
return false
}
})
overlays.add(marker.value)
}
}
@@ -702,6 +742,19 @@ class AlertDialogs(
var searchJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
val resultsScrollState = rememberScrollState()
BackHandler {
val shouldDismissKeyboard = locationName.isNotEmpty() || addressResults.isNotEmpty()
if (shouldDismissKeyboard) {
dismissKeyboard()
locationName = ""
addressResults = emptyList()
searchJob?.cancel()
searchJob = null
} else {
dismiss()
}
}
suspend fun search() {
if (locationSearchProvider == "google_maps") {
// Google Maps Search
@@ -946,6 +999,7 @@ class AlertDialogs(
modifier = Modifier
.fillMaxWidth()
.clickable {
dismissKeyboard()
marker.value?.position = GeoPoint(address.second.toDouble(), address.third.toDouble())
mapView.value?.controller?.setCenter(marker.value?.position)
mapView.value?.invalidate()
@@ -1093,7 +1147,7 @@ class AlertDialogs(
val lat = remember { mutableStateOf(coordinates.first.toString()) }
val lon = remember { mutableStateOf(coordinates.second.toString()) }
Dialog(
StandardDialog(
onDismissRequest = {
customCoordinatesDialog = false
},
@@ -1398,7 +1452,7 @@ class AlertDialogs(
// Add/Edit message dialog
if (showAddDialog) {
Dialog(
StandardDialog(
onDismissRequest = { showAddDialog = false },
properties = DialogProperties(
usePlatformDefaultWidth = false

View File

@@ -4,12 +4,28 @@ package me.eternal.purrfectsnap.ui.util
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Outline
import android.os.Build
import android.provider.Settings
import android.view.*
import android.view.View.OnAttachStateChangeListener
import androidx.activity.ComponentDialog
import androidx.activity.addCallback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.ExperimentalComposeUiApi
@@ -19,9 +35,12 @@ import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.*
import androidx.compose.ui.semantics.dialog
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.core.view.WindowCompat
import androidx.lifecycle.findViewTreeLifecycleOwner
@@ -33,6 +52,12 @@ import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import java.util.UUID
import kotlin.math.roundToInt
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext?.findActivity()
else -> null
}
class DialogProperties constructor(
val dismissOnBackPress: Boolean = true,
val dismissOnClickOutside: Boolean = true,
@@ -83,6 +108,17 @@ fun Dialog(
content: @Composable () -> Unit
) {
val view = LocalView.current
var forceInline by remember(view) { mutableStateOf(false) }
val hostActivity = remember(view) { view.context.findActivity() }
val shouldUseInline = forceInline || hostActivity == null || hostActivity.isFinishing || hostActivity.isDestroyed
if (shouldUseInline) {
InlineDialog(
onDismissRequest = onDismissRequest,
dismissOnClickOutside = properties.dismissOnClickOutside,
content = content
)
return
}
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val composition = rememberCompositionContext()
@@ -110,11 +146,30 @@ fun Dialog(
}
DisposableEffect(dialog) {
// Set the dialog's window type to TYPE_APPLICATION_OVERLAY so it's compatible with compose overlays
if (Settings.canDrawOverlays(view.context) && view.context !is Activity) {
dialog.window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
val showDialog = {
try {
dialog.prepareWindowForHost()
if (!dialog.isShowing) {
dialog.show()
}
} catch (_: WindowManager.BadTokenException) {
forceInline = true
}
}
if (view.isAttachedToWindow || view.windowToken != null || view.rootView?.windowToken != null) {
showDialog()
} else {
val listener = object : OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
v.removeOnAttachStateChangeListener(this)
showDialog()
}
override fun onViewDetachedFromWindow(v: View) = Unit
}
view.addOnAttachStateChangeListener(listener)
}
dialog.show()
onDispose {
dialog.dismiss()
@@ -131,6 +186,75 @@ fun Dialog(
}
}
@Composable
private fun InlineDialog(
onDismissRequest: () -> Unit,
dismissOnClickOutside: Boolean,
content: @Composable () -> Unit
) {
val density = LocalDensity.current
val displayMetrics = LocalContext.current.resources.displayMetrics
val screenWidthDp = with(density) { displayMetrics.widthPixels.toDp() }
val screenHeightDp = with(density) { displayMetrics.heightPixels.toDp() }
val interactionSource = remember { MutableInteractionSource() }
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
visible = true
}
Popup(
alignment = androidx.compose.ui.Alignment.Center,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = dismissOnClickOutside
),
onDismissRequest = onDismissRequest
) {
Box(
modifier = Modifier
.width(screenWidthDp)
.height(screenHeightDp)
.then(
if (dismissOnClickOutside) {
Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onDismissRequest
)
} else {
Modifier
}
)
.semantics { dialog() },
contentAlignment = androidx.compose.ui.Alignment.Center
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(180)) + scaleIn(
initialScale = 0.92f,
animationSpec = spring(dampingRatio = 0.82f, stiffness = 520f)
),
exit = fadeOut(animationSpec = tween(120)) + scaleOut(
targetScale = 0.96f,
animationSpec = tween(120)
)
) {
Box(
modifier = Modifier.clickable(
interactionSource = interactionSource,
indication = null,
onClick = {}
)
) {
content()
}
}
}
}
}
interface DialogWindowProvider {
val window: Window
}
@@ -279,6 +403,26 @@ private class DialogWrapper(
}
}
fun prepareWindowForHost() {
val hostActivity = composeView.context.findActivity()
val hostToken = composeView.applicationWindowToken
?: composeView.windowToken
?: composeView.rootView?.applicationWindowToken
?: composeView.rootView?.windowToken
if (hostActivity == null) {
when {
hostToken != null -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG)
Settings.canDrawOverlays(composeView.context) -> window?.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY)
}
}
if (hostToken != null) {
window?.attributes = window?.attributes?.apply {
token = hostToken
}
}
hostActivity?.let { setOwnerActivity(it) }
}
private fun setLayoutDirection(layoutDirection: LayoutDirection) {
dialogLayout.layoutDirection = when (layoutDirection) {
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR

View File

@@ -33,8 +33,8 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.4.6").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("286").get().toInt())
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.0").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("310").get().toInt())
rootProject.ext.set("applicationId", "me.eternal.purrfectsnap")
// buildHash: when PurrfectSnap or Snapchat is updated, mappings become outdated and auto-regenerate.
// Include version code so each release has a different hash; use random for uniqueness within same version.

View File

@@ -1,3 +1,70 @@
## v1.6.0
- New: Conversation Sound Effects!(Sound when you send or receive a msg while in chat)
- New: Call Metadata Notifier!
- New: Pin Messages!(Locally)
- Fix: Select highest quality media by comparing resolution(tq to Issac XT)
## v1.5.9
- New: Block Calls Feature!
- New: Unlock Zoom Limit Feature!
## v1.5.8
- New: Implemented 30Mbps Video and 320kbps/48kHz Audio bitrates(tq to Kaladin)
- Fix: Advanced hardware ISP processing modes for superior dynamic range and less noisy video footage(tq to Kaladin)
- Fix: Optimized 1MB socket buffers for high-speed throughput which should upload the video/snap faster then before(tq to Kaladin)
- New: Aphelion Task page went full layout overhaul with segmented "Active" and "Scheduled" tab views(tq to Kaladin)
- FixL Auto-Open Status Card: Restored real-time visibility of processed snaps and queue status(tq to Kaladin)
- New: Smart trickle which has background processing with resource awareness (Paused/Trickle/Normal states)(tq to Kaladin)
- New: Four different states for auto open processing so that the device can be usable with auto open in the background(tq to Kaladin)
- Fix: memory hogging of the auto open queues, now the device should run much faster without any lag or stuttures(tq to Kaladin)
- Fix: "dot in username issue" with the implementation of unique incrementing ((1).jpg), where previously it was overwriting the previous file(tq to Kaladin)
- Fix: Lock Indicator for the snaps, which should now accurately be working on all versions(tq to Kaladin)
- Fix: Pressing back while keyboard is open in spoofed coordinates would close the dialog
- Fix: Not able to scroll in the "Can't Login?" dialog
## v1.5.6
- New: Add "Can't login" hint directly in the login screen
## v1.5.5
- Fix: Custom Emoji now works for all devices!
- New: Story preview in story batch download dialog
- Fix: Auto Skip Stories getting stuck
- Fix: Stories ending up saving in .dat format
## v1.5.4
- Fix: Streak & Non-Streak category in Bulk Messaging Action for newer versions of snap
- Fix: Spoof Coordinates Title
- New: Changelogs feature
## v1.5.3
- New: Mark as Seen Mode(Limit per run[Custom] or Complete Queue)
- Fix: PurrfectSnap crash if you try to open any dialog setting through in-app overlay
- New: Translucent Dialogs & refreshed animation
- Fix: Adjusted Snap Preview Location
## v1.5.2
- New: Continuous snap sender feature!
- Fix: Snap send failure for E2E Chats
## v1.5.1
- Fix: Splitting issue for video snaps sent through gallery media send override!
- New: Toggle to turn off/on splitting for video snaps sent through send override
- Fix: Aphelion task page layout optimization(tq to Kaladin)
## v1.5.0
- Fix: Skip when marking as seen for newer versions of Snapchat
- New: Hide Conversation Toolbox UI
## v1.4.9
- Fix: Force AMOLED Theme for newer versions of Snapchat
- New: Redesign some dialogs(Call confirmation & Mark Snaps as seen)
## v1.4.8
- Fix: Crash Issues for some devices
- Fix: Crash when using the theme button in android 11 & 12(tq to Kaladin)
- Fix: Both side call recording for newer versions of snapchat
- Fix: Missing Accept Key button for E2E Encryption for newer versions of snapchat
## v1.4.6
- Fix: Half Swipe Notifications for newer versions of snapchat
- Fix: No Config import/export button if Aphelion theme is turned off

View File

@@ -0,0 +1,7 @@
package me.eternal.purrfectsnap.bridge;
interface AutoOpenInterface {
int getProcessedCount();
List<String> getQueueItems(); // returns JSON serialized SnapQueueItem list
void reset();
}

View File

@@ -20,4 +20,6 @@ interface MessagingBridge {
@nullable String updateMessage(String conversationId, long clientMessageId, String messageUpdate);
@nullable String getOneToOneConversationId(String userId);
me.eternal.purrfectsnap.bridge.AutoOpenInterface getAutoOpenInterface();
}

View File

@@ -1190,6 +1190,14 @@
"name": "Mark Snap as Seen Button",
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
},
"mark_snap_as_seen_processing_mode": {
"name": "Mark Snaps as Seen Mode",
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
},
"mark_snap_as_seen_limit": {
"name": "Mark Snaps as Seen Limit",
"description": "How many snaps to process per run when the mode is set to limit"
},
"skip_when_marking_as_seen": {
"name": "Skip When Marking as Seen",
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
@@ -3639,4 +3647,4 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
}
}

View File

@@ -1,4 +1,4 @@
{
{
"setup": {
"activity": {
"wrong_apk_title": "Wrong APK installed",
@@ -1254,6 +1254,14 @@
"name": "Mark Snap as Seen Button",
"description": "Adds a button to mark a Snap as seen when viewing it.\nThis will work even when Stealth Mode is enabled"
},
"mark_snap_as_seen_processing_mode": {
"name": "Mark Snaps as Seen Mode",
"description": "Choose whether to process a limited number of snaps per run or the entire queue at once"
},
"mark_snap_as_seen_limit": {
"name": "Mark Snaps as Seen Limit",
"description": "How many snaps to process per run when the mode is set to limit"
},
"skip_when_marking_as_seen": {
"name": "Skip When Marking as Seen",
"description": "Automatically skips to the next Snap when marking a Snap as seen.\nUse in combination with Mark Snap as Seen Button"
@@ -1284,6 +1292,22 @@
"name": "Call Start Confirmation",
"description": "Shows a confirmation dialog when starting a call"
},
"block_calls": {
"name": "Block Calls",
"description": "Blocks Snapchat call session updates so call UI and incoming call overlays do not appear"
},
"call_metadata_notifier": {
"name": "Call Metadata Notifier",
"description": "Shows a notification with captured call metadata after the call ends"
},
"conversation_sound_effects": {
"name": "Conversation Sound Effects",
"description": "Plays send and receive sounds inside an open conversation"
},
"conversation_sound_effects_style": {
"name": "Conversation Sound Style",
"description": "Choose the sound style used for in-conversation send and receive sounds"
},
"unlimited_conversation_pinning": {
"name": "Unlimited Conversation Pinning",
"description": "Allows you to pin an unlimited amount of conversations locally"
@@ -1603,6 +1627,13 @@
}
},
"auto_open_snaps": {
"title": "Auto Open Snaps",
"status_monitoring": "Monitoring",
"status_active": "Active",
"status_paused": "Paused",
"processed_count": "Opened",
"queue_size": "Queue",
"action_reset": "Reset Statistics",
"name": "Auto Open Snaps Settings",
"description": "Configure delay and queue settings for Auto Open Snaps",
"properties": {
@@ -1629,7 +1660,18 @@
"retry_delay": {
"name": "Retry Delay (ms)",
"description": "Delay in milliseconds between retry attempts"
}
},
"compact_notification": {
"name": "Auto Open Compact Notification",
"description": "Use a smaller, single-line notification for status updates"
},
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
"only_when_idle": {
"name": "Auto Open only when Idle",
"description": "Only process queue when the device is not in active use"
},
"pause_during_gaming": { "name": "Pause Auto Open During Gaming", "description": "Automatically slow down processing when a resource intensive app or a game is in the foreground" },
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." }
}
},
"auto_delete_sent_messages": {
@@ -1981,9 +2023,17 @@
"name": "HEVC Recording",
"description": "Uses HEVC (H.265) codec for video recording"
},
"video_record_timer": {
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": {
"name": "Video Recording Timer",
"description": "Shows a recording timer overlay when recording video"
},
"unlock_zoom_limit": {
"name": "Unlock Zoom Limit",
"description": "Overrides the max camera zoom Snapchat reads from the device"
},
"max_zoom_override": {
"name": "Max Zoom Override",
"description": "Maximum zoom ratio to report to Snapchat, for example 120"
}
}
},
@@ -2119,7 +2169,7 @@
}
}
},
"better_transcript": {
"network_optimization": { "name": "Network Optimization", "description": "Optimizes network socket buffers for higher throughput" }, "better_transcript": {
"name": "Better Transcript",
"description": "Improves the voice note transcript",
"properties": {
@@ -2190,6 +2240,10 @@
"force_message_encryption": {
"name": "Force Message Encryption",
"description": "Prevents sending encrypted messages to people who don't have E2E Encryption enabled only when multiple conversations are selected"
},
"hide_conversation_toolbox_ui": {
"name": "Hide Conversation Toolbox UI",
"description": "Hides the PurrfectSnap conversation toolbox button that appears in Snapchat when End-To-End Encryption is enabled"
}
}
},
@@ -2474,6 +2528,12 @@
"back_custom_frame_rate": {
"null": "Device default FPS"
},
"conversation_sound_effects_style": {
"imessage": "iMessage",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"subtle": "Subtle"
},
"force_voice_note_format": {
"null": "Use Snapchat default"
},
@@ -2510,6 +2570,10 @@
"remove_audio_note_duration": "Remove Audio Note Duration",
"remove_audio_note_transcript_capability": "Remove Audio Note Transcript Capability"
},
"mark_snap_as_seen_processing_mode": {
"limit": "Limit Per Run",
"complete": "Complete Queue"
},
"hide_ui_components": {
"hide_profile_call_buttons": "Remove Profile Call Buttons",
"hide_chat_call_buttons": "Remove Chat Call Buttons",
@@ -2848,7 +2912,9 @@
"download_button": "Download",
"delete_logged_message_button": "Delete Logged Message",
"show_chat_edit_history": "Show Chat Edit History",
"convert_message": "Convert Message"
"convert_message": "Convert Message",
"pin_local_message": "Pin Message Locally",
"unpin_local_message": "Unpin Local Message"
},
"chat_wallpaper_downloader": {
"download_button": "Download Chat Wallpaper"
@@ -3088,6 +3154,16 @@
"dialog_title": "Start Call",
"dialog_message": "Are you sure you want to start a call?"
},
"call_metadata_notifier": {
"notification_channel_name": "Call Metadata",
"notification_title": "Call Metadata Captured",
"notification_empty": "No call metadata was captured"
},
"local_pinned_messages": {
"banner_title": "Pinned Message",
"pinned_toast": "Pinned message locally",
"unpinned_toast": "Removed local pinned message"
},
"half_swipe_notifier": {
"notification_channel_name": "Half Swipe",
"notification_content_dm": "{friend} just half-swiped into your chat for {duration} seconds",
@@ -3183,9 +3259,7 @@
"export_failed_toast": "Failed to export account. Check logs for more info.",
"forced_logout_toast": "Removed account due to forced logout"
},
"auto_open_snaps": {
"title": "Auto Open Snaps",
"priority_title": "Auto Open Snaps (Priority)",
"auto_open_snaps": { "title": "Auto Open Snaps", "processed_count": "Opened", "queue_size": "Queue", "action_reset": "Reset Statistics", "priority_title": "Auto Open Snaps (Priority)",
"error_title": "Auto Open Snaps (Errors)",
"channel_description": "Notifications for auto-opening snaps queue status",
"priority_channel_description": "High priority notifications for auto-opening snaps",
@@ -3323,6 +3397,13 @@
"title": "Send media as",
"duration": "Duration: {duration}",
"saveable_snap_hint": "Make Snap saveable in the chat",
"single_send_hint": "Send as one snap",
"continuous_send_toggle": "Continuous snap sender",
"continuous_send_count_label": "Send count",
"continuous_send_count_placeholder": "Enter number of sends",
"continuous_send_hint": "This will send the same snap to the same recipient multiple times.",
"continuous_send_invalid_count": "Enter a valid send count greater than 0",
"continuous_send_single_send_conflict": "Continuous sending is not available while 'Send as one snap' is enabled for split media.",
"unlimited_duration": "Unlimited",
"schedule": "Schedule",
"select_time": "Select time",
@@ -3426,10 +3507,10 @@
"search": {
"placeholder": "Search"
},
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"filters": {
"newest_first": "Newest first",
"pick_a_date": "Pick a date",
"title": "Filters",
"search_by": "Search by",
"since": "Since",
"until": "Until",
@@ -3444,7 +3525,7 @@
"started_typing": "Started typing",
"stopped_typing": "Stopped typing",
"started_speaking": "Started speaking",
"stopped_speaking": "Stopped speaking",
"stopped_speaking": "Stopped speaking",
"started_peeking": "Started peeking",
"stopped_peeking": "Stopped peeking",
"message_read": "Read message",
@@ -3745,4 +3826,30 @@
"openai": "OpenAI",
"openrouter": "OpenRouter"
}
,
"tasks_no_tasks": "No tasks",
"tasks_no_active_tasks": "No active tasks",
"tasks_no_scheduled_tasks": "No scheduled snaps",
"tasks_tab_active": "Active",
"tasks_tab_scheduled": "Scheduled",
"tasks_clear_button_description": "Clear tasks",
"tasks_delete_button": "Delete",
"tasks_merge_button": "Merge",
"tasks_summary_active": "{active} active · {recent} recent",
"tasks_summary_idle": "Idle · {recent} recent",
"tasks_running_count": "{count} running",
"tasks_tagline": "Monitor and manage background actions",
"tasks_failed_to_open_file": "Failed to open file",
"tasks_merge_files_toast": "Merging {count} files",
"tasks_remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
"tasks_remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
}

View File

@@ -58,6 +58,15 @@ class Camera : ConfigContainer() {
val overrideFrontResolution get() = _overrideFrontResolution
val overrideBackResolution get() = _overrideBackResolution
val videoRecordTimer = boolean("video_record_timer")
val unlockZoomLimit = boolean("unlock_zoom_limit") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val maxZoomOverride = float("max_zoom_override", 120f) {
requireRestart()
addFlags(ConfigFlag.NO_TRANSLATE)
inputCheck = { (it.toFloatOrNull() ?: 0f) in 1f..500f }
}
val audioVideoOptimizations = boolean("audio_video", defaultValue = true) { requireRestart() }
val cameraOptimizations = boolean("camera_tweaks", defaultValue = false) { addNotices(FeatureNotice.UNSTABLE); requireRestart() }
val customResolution = string("custom_resolution") { addNotices(FeatureNotice.UNSTABLE); inputCheck = { it.matches(Regex("\\d+x\\d+")) } }
}

View File

@@ -51,6 +51,7 @@ class Experimental : ConfigContainer() {
class E2EEConfig : ConfigContainer(hasGlobalState = true) {
val encryptedMessageIndicator = boolean("encrypted_message_indicator")
val forceMessageEncryption = boolean("force_message_encryption")
val hideConversationToolboxUi = boolean("hide_conversation_toolbox_ui")
}
class AccountSwitcherConfig : ConfigContainer(hasGlobalState = true) {
@@ -67,6 +68,7 @@ class Experimental : ConfigContainer() {
val mediaFilePicker = boolean("media_file_picker") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val storyLogger = boolean("story_logger") { requireRestart(); addNotices(FeatureNotice.UNSTABLE); }
val accountSwitcher = container("account_switcher", AccountSwitcherConfig()) { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val networkOptimization = boolean("network_optimization") { requireRestart() }
val betterTranscript = container("better_transcript", BetterTranscriptConfig()) { requireRestart() }
val voiceNoteAutoPlay = boolean("voice_note_auto_play") { requireRestart() }
val friendNotes = boolean("friend_notes") { requireRestart() }

View File

@@ -175,7 +175,13 @@ class MessagingTweaks : ConfigContainer() {
val retryDelay = integer("retry_delay", defaultValue = 3000) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null }
}
val compactNotification = boolean("compact_notification", false)
// Resource Intelligence: Smart triggers for battery and data safety
val onlyOnWifi = boolean("only_on_wifi", false)
val onlyWhenIdle = boolean("only_when_idle", false)
val pauseDuringGaming = boolean("pause_during_gaming", false)
val safeProcessing = boolean("safe_processing", true)
}
class AutoDeleteSentMessagesConfig : ConfigContainer(hasGlobalState = true) {
@@ -205,11 +211,25 @@ class MessagingTweaks : ConfigContainer() {
val unlimitedSnapViewTime = boolean("unlimited_snap_view_time")
val autoMarkAsRead = multiple("auto_mark_as_read", "snap_reply", "conversation_read", "save_snap_in_chat") { requireRestart() }
val markSnapAsSeenButton = boolean("mark_snap_as_seen_button") { requireRestart() }
val markSnapAsSeenProcessingMode = unique("mark_snap_as_seen_processing_mode", "limit", "complete").apply {
set("limit")
}
val markSnapAsSeenLimit = integer("mark_snap_as_seen_limit", defaultValue = 50) {
inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null }
}
val skipWhenMarkingAsSeen = boolean("skip_when_marking_as_seen") { requireRestart() }
val loopMediaPlayback = boolean("loop_media_playback") { requireRestart() }
val disableReplayInFF = boolean("disable_replay_in_ff")
val halfSwipeNotifier = container("half_swipe_notifier", HalfSwipeNotifierConfig()) { requireRestart()}
val callStartConfirmation = boolean("call_start_confirmation") { requireRestart() }
val blockCalls = boolean("block_calls") { requireRestart() }
val callMetadataNotifier = boolean("call_metadata_notifier") { requireRestart(); addNotices(FeatureNotice.UNSTABLE) }
val conversationSoundEffects = boolean("conversation_sound_effects") { requireRestart() }
val conversationSoundEffectsStyle = unique("conversation_sound_effects_style", "imessage", "telegram", "whatsapp", "subtle") {
requireRestart()
customOptionTranslationPath = "conversation_sound_effects_style"
addFlags(ConfigFlag.NO_TRANSLATE)
}.apply { set("imessage") }
val unlimitedConversationPinning = boolean("unlimited_conversation_pinning") { requireRestart() }
val disableSnapModeRestrictions = boolean("disable_snap_mode_restrictions") { requireRestart() }
val autoSaveMessagesInConversations = multiple("auto_save_messages_in_conversations",

View File

@@ -52,6 +52,39 @@ enum class FileType(
return result.toString()
}
private fun looksLikeIsoBmffVideo(array: ByteArray): Boolean {
if (array.size < 12) return false
// ISO BMFF containers like MP4 expose an `ftyp` box at byte offset 4.
if (array[4] != 'f'.code.toByte() ||
array[5] != 't'.code.toByte() ||
array[6] != 'y'.code.toByte() ||
array[7] != 'p'.code.toByte()
) {
return false
}
val majorBrand = String(array, 8, 4, Charsets.US_ASCII).trim('\u0000').lowercase()
return majorBrand in setOf(
"mp41",
"mp42",
"isom",
"iso2",
"iso3",
"iso4",
"iso5",
"iso6",
"avc1",
"dash",
"mif1",
"msnv",
"3gp4",
"3gp5",
"3gp6",
"3g2a",
"3g2b"
)
}
fun fromFile(file: File): FileType {
file.inputStream().use { inputStream ->
val buffer = ByteArray(16)
@@ -64,7 +97,8 @@ enum class FileType(
val headerBytes = ByteArray(16)
System.arraycopy(array, 0, headerBytes, 0, 16)
val hex = bytesToHex(headerBytes)
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value
?: if (looksLikeIsoBmffVideo(headerBytes)) MP4 else UNKNOWN
}
fun fromInputStream(inputStream: InputStream): FileType {

View File

@@ -2,21 +2,38 @@ package me.eternal.purrfectsnap.core
import android.system.Os
import android.view.ViewGroup
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.outlined.Cancel
import androidx.compose.material.icons.rounded.NotInterested
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.ui.window.Dialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.common.bridge.FileHandleScope
import me.eternal.purrfectsnap.common.bridge.toWrapper
@@ -25,6 +42,8 @@ import me.eternal.purrfectsnap.common.config.VersionRequirement
import me.eternal.purrfectsnap.common.ui.createComposeView
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.ui.CustomComposable
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
@@ -48,6 +67,201 @@ class SecurityFeatures(
transact(this, 0)?.toString(2)?.padStart(32, '0')?.count { it == '1' }
}
private fun isLoginSignupActivity() = context.mainActivity?.javaClass?.name?.endsWith("LoginSignupActivity") == true
@Composable
private fun LoginSignupHelpButton(onClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 16.dp),
contentAlignment = Alignment.TopCenter
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(
Brush.horizontalGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.92f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.62f)
)
),
RoundedCornerShape(999.dp)
)
.border(
BorderStroke(1.dp, PurrfectOverlayPalette.textPrimary.copy(alpha = 0.18f)),
RoundedCornerShape(999.dp)
)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.16f))
.border(BorderStroke(1.dp, PurrfectOverlayPalette.textPrimary.copy(alpha = 0.28f)), CircleShape)
) {
Icon(
imageVector = Icons.Filled.HelpOutline,
contentDescription = "Login Help",
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
Text(
text = "Can't Login?",
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp
)
}
}
}
@Composable
private fun LoginSignupHelpDialog(onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
PurrfectOverlayTheme {
val shape = RoundedCornerShape(20.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = shape,
color = Color.Transparent,
border = BorderStroke(
1.dp,
Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.55f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.35f)
)
)
),
shadowElevation = 0.dp,
tonalElevation = 0.dp
) {
Box(
modifier = Modifier
.background(PurrfectOverlayPalette.cardOverlay, shape)
.padding(20.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
val scrollState = rememberScrollState()
Icon(
Icons.Filled.Info,
contentDescription = null,
tint = PurrfectOverlayPalette.textPrimary,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(28.dp)
)
Text(
text = context.translation["setup.mappings.notice_title"],
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = PurrfectOverlayPalette.textPrimary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(end = 10.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = context.translation["setup.mappings.notice_intro"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = "For non-rooted users:",
color = PurrfectOverlayPalette.textPrimary,
fontWeight = FontWeight.SemiBold
)
Text(
text = context.translation["setup.mappings.notice_step_1"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_step_2"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_step_3"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
Text(
text = context.translation["setup.mappings.notice_rooted_title"],
color = PurrfectOverlayPalette.textPrimary,
fontWeight = FontWeight.SemiBold
)
Text(
text = context.translation["setup.mappings.notice_rooted_body"],
color = PurrfectOverlayPalette.textSecondary,
textAlign = TextAlign.Start
)
}
val maxScroll = scrollState.maxValue
val thumbRatio = if (maxScroll > 0) {
((scrollState.value.toFloat() / maxScroll.toFloat()) * 0.7f).coerceIn(0f, 0.7f)
} else 0f
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.width(4.dp)
.fillMaxHeight()
.background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(999.dp))
) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(if (maxScroll > 0) 0.28f else 1f)
.offset(y = (320.dp * thumbRatio))
.background(
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.75f),
RoundedCornerShape(999.dp)
)
)
}
}
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.92f),
contentColor = Color.Black
),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text("OK")
}
}
}
}
}
}
}
fun init() {
val snapchatVersionCode = context.androidContext.packageManager?.getPackageInfo(context.androidContext.packageName, 0)?.longVersionCode ?: throw IllegalStateException("Failed to get version code")
var shouldDisablePlugin = MOD_DETECTION_VERSION_CHECK.checkVersion(snapchatVersionCode)?.second == VersionRequirement.OLDER_REQUIRED
@@ -101,6 +315,34 @@ class SecurityFeatures(
}
}
lateinit var loginHelpComposable: CustomComposable
loginHelpComposable = {
var showDialog by remember { mutableStateOf(false) }
var isLoginScreen by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
while (true) {
val currentlyInLogin = isLoginSignupActivity()
isLoginScreen = currentlyInLogin
if (!currentlyInLogin) showDialog = false
delay(150)
}
}
if (isLoginScreen) {
LoginSignupHelpButton(
onClick = { showDialog = true }
)
}
if (isLoginScreen && showDialog) {
LoginSignupHelpDialog(
onDismiss = { showDialog = false }
)
}
}
context.inAppOverlay.addCustomComposable(loginHelpComposable)
if (!context.disablePlugin) return
val allowedEPs = listOf(

View File

@@ -113,6 +113,18 @@ class BulkMessagingAction : AbstractAction() {
private val translation by lazy { context.translation.getCategory("bulk_messaging_action") }
private val betterLocation by lazy { context.feature(BetterLocation::class) }
private fun hasReliableStreak(friend: FriendInfo, streakFeedUserIds: Set<String>): Boolean {
val userId = friend.userId ?: return false
if (userId in streakFeedUserIds) return true
if (friend.streakExpirationTimestamp > 0L) return true
if (friend.streakLength > 0) return true
val categories = friend.friendmojiCategories?.split(",") ?: return false
return categories.any { category ->
category.contains("streak", ignoreCase = true) ||
category.contains("hourglass", ignoreCase = true)
}
}
private object BulkMessagingPalette {
val background = Brush.verticalGradient(
listOf(
@@ -286,7 +298,12 @@ class BulkMessagingAction : AbstractAction() {
}
}
private fun filterFriends(friends: List<FriendInfo>, filter: Filter, nameFilter: String): List<FriendInfo> {
private fun filterFriends(
friends: List<FriendInfo>,
filter: Filter,
nameFilter: String,
streakFeedUserIds: Set<String> = emptySet()
): List<FriendInfo> {
val userIdBlacklist = arrayOf(
context.database.myUserId,
"b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai
@@ -310,8 +327,12 @@ class BulkMessagingAction : AbstractAction() {
Filter.SUGGESTED -> friend.friendLinkType == FriendLinkType.SUGGESTED.value
Filter.DELETED -> friend.friendLinkType == FriendLinkType.DELETED.value
Filter.BUSINESS_ACCOUNTS -> friend.businessCategory > 0
Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0 && friend.streakLength != 0
Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value&& friend.addedTimestamp > 0 && friend.streakLength == 0
Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value &&
friend.addedTimestamp > 0 &&
hasReliableStreak(friend, streakFeedUserIds)
Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value &&
friend.addedTimestamp > 0 &&
!hasReliableStreak(friend, streakFeedUserIds)
Filter.FOLLOWING -> {
val isFollowing = friend.friendLinkType == FriendLinkType.FOLLOWING.value ||
(friend.friendLinkType == FriendLinkType.OUTGOING.value &&
@@ -390,10 +411,21 @@ class BulkMessagingAction : AbstractAction() {
val incomingRequestUserIds = if (filter == Filter.INCOMING || filter == Filter.INCOMING_FOLLOWER) {
runCatching { context.database.getIncomingRequestUserIds() }.getOrElse { emptySet() }
} else emptySet()
val streakFeedUserIds = if (filter == Filter.STREAKS || filter == Filter.NON_STREAKS) {
runCatching {
context.database.getFeedEntries(Int.MAX_VALUE)
.filter { it.conversationType == 0 && it.participantsSize == 2 }
.filter { (it.streakCount ?: 0) > 0 || (it.streakExpirationTimestampMs ?: 0L) > 0L }
.mapNotNull { entry ->
entry.friendUserId ?: entry.participants?.firstOrNull { id -> id != context.database.myUserId }
}
.toSet()
}.getOrElse { emptySet() }
} else emptySet()
val newFriends = if (conversationType == ConversationType.FRIENDS_ONLY || conversationType == ConversationType.BOTH) {
context.database.getAllFriends().let { friends ->
filterFriends(friends, filter, nameFilter)
filterFriends(friends, filter, nameFilter, streakFeedUserIds)
}
.filter { it.userId?.let { id -> !hiddenFriendIds.contains(id) } == true }
.filter { friend ->

View File

@@ -34,6 +34,8 @@ abstract class Feature(
open fun init() {}
open fun onBridgeAction(action: String, extras: Map<String, Any>?, callback: (Any?) -> Unit) {}
protected fun findClass(name: String): Class<*> {
return context.androidContext.classLoader.loadClass(name)

View File

@@ -114,6 +114,10 @@ class FeatureManager(
HideFriendFeedEntry(),
RequerySqlite(),
RefreshFriendSuggestions(),
LocalPinnedMessages(),
BlockCalls(),
CallMetadataNotifier(),
ConversationSoundEffects(),
CallButtonsOverride(),
SnapPreview(),
BypassScreenshotDetection(),

View File

@@ -36,4 +36,20 @@ abstract class MessagingRuleFeature(name: String, val ruleType: MessagingRuleTyp
}
return state
}
override fun onBridgeAction(action: String, extras: Map<String, Any>?, callback: (Any?) -> Unit) {
if (action == "get_state") {
val conversationId = extras?.get("conversationId") as? String ?: return
callback(getState(conversationId))
return
}
if (action == "set_state") {
val conversationId = extras?.get("conversationId") as? String ?: return
val state = extras["state"] as? Boolean ?: return
setState(conversationId, state)
callback(true)
return
}
super.onBridgeAction(action, extras, callback)
}
}

View File

@@ -6,8 +6,13 @@ import android.media.AudioFormat
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.core.ui.InAppOverlay
import me.eternal.purrfectsnap.bridge.call.CallDownloadSession
@@ -26,12 +31,21 @@ class CallRecorder : Feature("Call Recorder") {
private var wasInCall = false
private var callDownloadSession: CallDownloadSession? = null
private val streams = ConcurrentHashMap<Int, CallStreamWrapper>()
private val activeRemoteStreams = ConcurrentHashMap.newKeySet<Int>()
private var fallbackMicRecord: AudioRecord? = null
private var fallbackMicJob: Job? = null
private var fallbackMicStartupJob: Job? = null
private var pendingCallEndJob: Job? = null
private var lastRemoteActivityTimestamp = 0L
private var selfSideStreamOpened = false
private val uiState get() = context.inAppOverlay.callRecorderState
private val callRecorderConfig get() = context.config.downloader.callRecorder
inner class CallStreamWrapper(
private val audioFormat: AudioFormat,
private val sourceLabel: String = "unknown",
private val onStreamOpened: (() -> Unit)? = null,
private val startTimestamp: Long = System.currentTimeMillis(),
) {
private var stream: OutputStream? = null
@@ -49,6 +63,11 @@ class CallRecorder : Feature("Call Recorder") {
audioFormat.encoding
) ?: return
)
context.log.verbose(
"Opened call stream source=$sourceLabel sampleRate=${audioFormat.sampleRate} channels=${audioFormat.channelCount} encoding=${audioFormat.encoding}",
"CallRecorder"
)
onStreamOpened?.invoke()
}
}
runCatching { stream?.write(buffer) }
@@ -63,6 +82,9 @@ class CallRecorder : Feature("Call Recorder") {
private fun finalizeSession() {
val session = callDownloadSession ?: return
context.log.verbose("Finalizing call recording session")
pendingCallEndJob?.cancel()
pendingCallEndJob = null
stopFallbackMicCapture("finalizeSession")
runCatching { session.end() }
callDownloadSession = null
streams.values.forEach { it.close() }
@@ -80,12 +102,14 @@ class CallRecorder : Feature("Call Recorder") {
}
ensureSessionStarted()
scheduleFallbackMicCapture()
}
}
private fun stopRecording() {
if (uiState.isRecording) {
uiState.isRecording = false
stopFallbackMicCapture("stopRecording")
finalizeSession()
}
}
@@ -93,6 +117,11 @@ class CallRecorder : Feature("Call Recorder") {
private fun onCallStarted(conversationId: String) {
if (wasInCall) return
wasInCall = true
activeRemoteStreams.clear()
lastRemoteActivityTimestamp = 0L
pendingCallEndJob?.cancel()
pendingCallEndJob = null
selfSideStreamOpened = false
val author = (if (context.database.getConversationType(conversationId) == 1) {
context.database.getFeedEntryByConversationId(conversationId)?.feedDisplayName
@@ -120,6 +149,11 @@ class CallRecorder : Feature("Call Recorder") {
private fun onCallEnded() {
context.log.verbose("onCallEnded cleanup. wasInCall=$wasInCall, showOverlay=${uiState.showOverlay}")
wasInCall = false
activeRemoteStreams.clear()
lastRemoteActivityTimestamp = 0L
pendingCallEndJob?.cancel()
pendingCallEndJob = null
stopFallbackMicCapture("onCallEnded")
finalizeSession()
streams.clear()
@@ -178,6 +212,36 @@ class CallRecorder : Feature("Call Recorder") {
}
}
private fun markRemoteStreamActive(streamId: Int, reason: String) {
lastRemoteActivityTimestamp = System.currentTimeMillis()
pendingCallEndJob?.cancel()
pendingCallEndJob = null
if (activeRemoteStreams.add(streamId)) {
context.log.verbose("Remote stream active id=$streamId reason=$reason", "CallRecorder")
}
}
private fun markRemoteStreamInactive(streamId: Int, reason: String) {
if (activeRemoteStreams.remove(streamId)) {
context.log.verbose("Remote stream inactive id=$streamId reason=$reason", "CallRecorder")
}
scheduleCallEndCheck(reason)
}
private fun scheduleCallEndCheck(reason: String, delayMs: Long = 1500L) {
if (!wasInCall || lastRemoteActivityTimestamp == 0L || activeRemoteStreams.isNotEmpty()) return
pendingCallEndJob?.cancel()
pendingCallEndJob = context.coroutineScope.launch {
delay(delayMs)
if (!wasInCall) return@launch
if (activeRemoteStreams.isNotEmpty()) return@launch
val idleFor = System.currentTimeMillis() - lastRemoteActivityTimestamp
if (idleFor < delayMs) return@launch
context.log.verbose("Call end detected via remote inactivity reason=$reason idleFor=${idleFor}ms", "CallRecorder")
onCallEnded()
}
}
private fun ensureSessionStarted() {
if (callDownloadSession != null) return
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
@@ -186,6 +250,209 @@ class CallRecorder : Feature("Call Recorder") {
onCallStarted(conversationId)
}
private fun isCallContextActive(): Boolean {
return wasInCall || uiState.showOverlay || uiState.isRecording
}
private fun isDirectVoiceCaptureSource(audioSource: Int?): Boolean {
return audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION ||
audioSource == MediaRecorder.AudioSource.VOICE_CALL ||
audioSource == MediaRecorder.AudioSource.VOICE_UPLINK
}
private fun isLikelyCallMicSource(audioSource: Int?): Boolean {
return audioSource == MediaRecorder.AudioSource.DEFAULT ||
audioSource == MediaRecorder.AudioSource.MIC ||
audioSource == MediaRecorder.AudioSource.VOICE_RECOGNITION ||
audioSource == MediaRecorder.AudioSource.UNPROCESSED ||
audioSource == MediaRecorder.AudioSource.VOICE_PERFORMANCE
}
private fun registerAudioRecordStream(audioRecord: AudioRecord, reason: String): CallStreamWrapper? {
val streamId = audioRecord.hashCode()
streams[streamId]?.let { return it }
val audioSource = runCatching { audioRecord.audioSource }.getOrNull()
val shouldCapture = isDirectVoiceCaptureSource(audioSource) ||
(isCallContextActive() && isLikelyCallMicSource(audioSource))
if (!shouldCapture) return null
val format = runCatching { audioRecord.format }.getOrNull() ?: return null
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
return CallStreamWrapper(
audioFormat = format,
sourceLabel = "self-internal:$reason",
onStreamOpened = {
selfSideStreamOpened = true
if (audioRecord !== fallbackMicRecord) {
stopFallbackMicCapture("internalSelfStreamOpened")
}
}
).also {
streams[streamId] = it
context.log.verbose(
"Registered AudioRecord stream source=$audioSource reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
"CallRecorder"
)
if (isDirectVoiceCaptureSource(audioSource) || isCallContextActive()) {
ensureSessionStarted()
}
}
}
private fun shouldCaptureSelfSide(): Boolean {
return callRecorderConfig.callRecorder.get() != "only_record_others"
}
private fun scheduleFallbackMicCapture() {
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null) return
fallbackMicStartupJob?.cancel()
fallbackMicStartupJob = context.coroutineScope.launch {
delay(1200)
if (!isActive || !uiState.isRecording || selfSideStreamOpened || fallbackMicJob != null) return@launch
startFallbackMicCapture()
}
}
private fun startFallbackMicCapture() {
if (!shouldCaptureSelfSide() || selfSideStreamOpened || fallbackMicJob != null || !uiState.isRecording) return
val sampleRate = 48_000
val channelMask = AudioFormat.CHANNEL_IN_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBufferSize <= 0) {
context.log.warn("Fallback mic capture unavailable: invalid min buffer size $minBufferSize", "CallRecorder")
return
}
val audioFormat = AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channelMask)
.setEncoding(encoding)
.build()
val audioRecord = runCatching {
AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
.setAudioFormat(audioFormat)
.setBufferSizeInBytes(minBufferSize * 2)
.build()
}.getOrElse {
context.log.error("Failed to create fallback mic recorder", it)
return
}
if (audioRecord.state != AudioRecord.STATE_INITIALIZED) {
context.log.warn("Fallback mic recorder failed to initialize", "CallRecorder")
runCatching { audioRecord.release() }
return
}
fallbackMicRecord = audioRecord
context.log.verbose("Starting fallback mic capture", "CallRecorder")
fallbackMicJob = context.coroutineScope.launch(Dispatchers.IO) {
val buffer = ByteArray(minBufferSize.coerceAtLeast(2048))
val fallbackWrapper = CallStreamWrapper(
audioFormat = audioFormat,
sourceLabel = "self-fallback",
onStreamOpened = {
selfSideStreamOpened = true
}
)
val echoCanceler = AcousticEchoCanceler.create(audioRecord.audioSessionId)?.apply {
enabled = true
}
val noiseSuppressor = NoiseSuppressor.create(audioRecord.audioSessionId)?.apply {
enabled = true
}
try {
audioRecord.startRecording()
while (isActive && uiState.isRecording && isCallContextActive() && fallbackMicRecord === audioRecord) {
val bytesRead = runCatching {
audioRecord.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
}.getOrElse {
context.log.error("Fallback mic read failed", it)
break
}
if (bytesRead > 0) {
fallbackWrapper.write(buffer.copyOf(bytesRead))
} else {
delay(10)
}
}
} catch (e: Exception) {
context.log.error("Fallback mic capture crashed", e)
} finally {
fallbackWrapper.close()
runCatching { audioRecord.stop() }
echoCanceler?.release()
noiseSuppressor?.release()
runCatching { audioRecord.release() }
if (fallbackMicRecord === audioRecord) {
fallbackMicRecord = null
fallbackMicJob = null
}
context.log.verbose("Stopped fallback mic capture", "CallRecorder")
}
}
}
private fun stopFallbackMicCapture(reason: String) {
fallbackMicStartupJob?.cancel()
fallbackMicStartupJob = null
if (fallbackMicJob != null || fallbackMicRecord != null) {
context.log.verbose("Stopping fallback mic capture reason=$reason", "CallRecorder")
}
fallbackMicJob?.cancel()
fallbackMicJob = null
fallbackMicRecord?.let { record ->
runCatching { record.stop() }
runCatching { record.release() }
}
fallbackMicRecord = null
}
private fun isVoiceCommunicationTrack(attributes: AudioAttributes?, streamType: Int?): Boolean {
return attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
streamType == AudioManager.STREAM_VOICE_CALL ||
streamType == 6
}
private fun registerAudioTrackStream(audioTrack: AudioTrack, reason: String): CallStreamWrapper? {
val streamId = audioTrack.hashCode()
streams[streamId]?.let { return it }
val attributes = runCatching { audioTrack.audioAttributes }.getOrNull()
val streamType = runCatching { audioTrack.streamType }.getOrNull()
val isVoiceCommunication = isVoiceCommunicationTrack(attributes, streamType)
val shouldCapture = isVoiceCommunication ||
(isCallContextActive() && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
if (!shouldCapture) return null
val format = runCatching { audioTrack.format }.getOrNull() ?: return null
if (format.sampleRate <= 0 || format.channelCount <= 0) return null
return CallStreamWrapper(
audioFormat = format,
sourceLabel = "remote:$reason"
).also {
streams[streamId] = it
markRemoteStreamActive(streamId, "register:$reason")
context.log.verbose(
"Registered AudioTrack stream streamType=$streamType usage=${attributes?.usage} reason=$reason sampleRate=${format.sampleRate} channels=${format.channelCount}",
"CallRecorder"
)
if (isVoiceCommunication || isCallContextActive()) {
ensureSessionStarted()
}
}
}
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)
@@ -209,6 +476,13 @@ class CallRecorder : Feature("Call Recorder") {
}
}
private fun copyFloatArrayToByteArray(data: FloatArray, offset: Int, sampleCount: Int): ByteArray? {
val (safeOffset, safeLength) = clampCopyRange(offset, sampleCount, data.size) ?: return null
return ByteArray(safeLength * Float.SIZE_BYTES).also {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().put(data, safeOffset, safeLength)
}
}
override fun init() {
if (callRecorderConfig.callRecorder.getNullable() == null) return
@@ -224,30 +498,16 @@ class CallRecorder : Feature("Call Recorder") {
AudioRecord::class.java.apply {
if (recorderConfig == "only_record_others") return@apply
hookConstructor(HookStage.AFTER) { param ->
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
val audioSource = runCatching { param.arg<Int>(0) }.getOrNull()
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
val shouldCapture = isVoiceCommunication ||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
if (shouldCapture) {
val format = AudioFormat.Builder()
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
.build()
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
if (isVoiceCommunication) {
ensureSessionStarted()
}
}
registerAudioRecordStream(param.thisObject<AudioRecord>(), "constructor")
}
hook("read", HookStage.AFTER) { param ->
val result = param.getResult() as? Int ?: 0
if (result <= 0) return@hook
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val audioRecord = param.thisObject<AudioRecord>()
val wrapper = streams[param.thisObject<Any>().hashCode()]
?: registerAudioRecordStream(audioRecord, "read")
?: return@hook
val buffer = when (val data = param.arg<Any>(0)) {
is ByteBuffer -> copyAudioRecordByteBuffer(data, result) ?: return@hook
@@ -263,11 +523,19 @@ class CallRecorder : Feature("Call Recorder") {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
}
}
is FloatArray -> {
val offset = param.argNullable<Int>(1) ?: 0
copyFloatArrayToByteArray(data, offset, result) ?: return@hook
}
else -> return@hook
}
wrapper.write(buffer)
}
hook("startRecording", HookStage.AFTER) {
registerAudioRecordStream(it.thisObject<AudioRecord>(), "startRecording")
}
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
hook("release", HookStage.BEFORE) {
streams.remove(it.thisObject<Any>().hashCode())?.close()
@@ -278,29 +546,15 @@ class CallRecorder : Feature("Call Recorder") {
AudioTrack::class.java.apply {
if (recorderConfig == "only_record_self") return@apply
hookConstructor(HookStage.AFTER) { param ->
val attributes = runCatching { param.arg<AudioAttributes>(0) }.getOrNull()
val streamType = runCatching { param.arg<Int>(0) }.getOrNull()
val isVoiceCommunication = attributes?.usage == AudioAttributes.USAGE_VOICE_COMMUNICATION ||
streamType == AudioManager.STREAM_VOICE_CALL ||
streamType == 6
val shouldCapture = isVoiceCommunication ||
(wasInCall && attributes?.usage == AudioAttributes.USAGE_UNKNOWN)
if (shouldCapture) {
val format = AudioFormat.Builder()
.setSampleRate(if (attributes != null) param.arg<AudioFormat>(1).sampleRate else param.arg(1))
.setChannelMask(if (attributes != null) param.arg<AudioFormat>(1).channelMask else param.arg(2))
.setEncoding(if (attributes != null) param.arg<AudioFormat>(1).encoding else param.arg(3))
.build()
streams[param.thisObject<Any>().hashCode()] = CallStreamWrapper(format)
if (isVoiceCommunication) {
ensureSessionStarted()
}
}
registerAudioTrackStream(param.thisObject<AudioTrack>(), "constructor")
}
hook("write", HookStage.BEFORE) { param ->
val wrapper = streams[param.thisObject<Any>().hashCode()] ?: return@hook
val streamId = param.thisObject<Any>().hashCode()
markRemoteStreamActive(streamId, "write")
val wrapper = streams[streamId]
?: registerAudioTrackStream(param.thisObject<AudioTrack>(), "write")
?: return@hook
val data = param.arg<Any>(0)
val buffer = when (data) {
@@ -328,13 +582,34 @@ class CallRecorder : Feature("Call Recorder") {
ByteBuffer.wrap(it).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(data, safeOffset, safeLength)
}
}
is FloatArray -> {
val offset = param.argNullable<Int>(1) ?: 0
val requestedSize = param.argNullable<Int>(2) ?: data.size
copyFloatArrayToByteArray(data, offset, requestedSize) ?: return@hook
}
else -> return@hook
}
wrapper.write(buffer)
}
hook("stop", HookStage.BEFORE) { checkStreamsAndCleanup() }
hook("play", HookStage.AFTER) {
val audioTrack = it.thisObject<AudioTrack>()
markRemoteStreamActive(audioTrack.hashCode(), "play")
registerAudioTrackStream(audioTrack, "play")
}
hook("stop", HookStage.AFTER) {
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "stop")
checkStreamsAndCleanup()
}
hook("pause", HookStage.AFTER) {
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "pause")
}
hook("flush", HookStage.AFTER) {
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "flush")
}
hook("release", HookStage.BEFORE) {
markRemoteStreamInactive(it.thisObject<Any>().hashCode(), "release")
streams.remove(it.thisObject<Any>().hashCode())?.close()
checkStreamsAndCleanup()
}

View File

@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.media.MediaMetadataRetriever
import android.view.Gravity
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ImageView
@@ -11,11 +12,14 @@ import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@@ -26,17 +30,27 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateListOf
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.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
@@ -84,8 +98,12 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.SnapCipherMode
import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPairUrlSafe
import me.eternal.purrfectsnap.core.wrapper.impl.media.HybridEncryptionResolver
import me.eternal.purrfectsnap.core.wrapper.AbstractWrapper
import okhttp3.OkHttpClient
import okhttp3.Request
import java.nio.file.Paths
import java.util.UUID
import java.util.Collections
import java.util.IdentityHashMap
import kotlin.coroutines.suspendCoroutine
import kotlin.math.absoluteValue
import android.util.Base64
@@ -107,6 +125,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
private var lastSeenMediaInfoMap: MutableMap<SplitMediaAssetType, MediaInfo>? = null
var lastSeenMapParams: ParamMap? = null
private set
private val storyPreviewCache = mutableMapOf<String, MutableMap<Int, Bitmap>>()
@Volatile
private var pendingBatchDownloadIndices: MutableList<Int>? = null
@Volatile
@@ -248,14 +267,67 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
val tr = context.translation.getCategory("download_processor.story_snap_dialog")
val cancelStr = context.translation["button.cancel"]
val downloadStr = context.translation["button.download"]
val previewCacheKey = buildString {
append(paramMap["STORY_ID"]?.toString() ?: "story")
append("|")
append(paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: "user")
append("|")
append(totalCount)
}
context.runOnUiThread {
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
val selected = remember { mutableStateListOf<Int>().apply { add(currentIndex) } }
val previewBitmaps = remember { mutableStateMapOf<Int, Bitmap?>() }
val previewLoading = remember { mutableStateMapOf<Int, Boolean>() }
LaunchedEffect(Unit) {
if (!selected.contains(currentIndex)) selected.add(currentIndex)
mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri?.let { currentUri ->
previewLoading[currentIndex] = true
previewBitmaps[currentIndex] = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(currentUri) }
previewLoading[currentIndex] = false
}
synchronized(storyPreviewCache) {
storyPreviewCache[previewCacheKey]?.forEach { (index, bitmap) ->
previewBitmaps[index] = bitmap
}
}
}
LaunchedEffect(previewCacheKey) {
val overlay = context.feature(OperaStoryOverlay::class)
val cachedIndices = synchronized(storyPreviewCache) {
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }.keys.toSet()
}
val indicesToScan = (0 until totalCount).filter { it != currentIndex && it !in cachedIndices }
try {
for (targetIndex in indicesToScan) {
val jumped = withContext(Dispatchers.Main) {
overlay.requestJumpToSnap(targetIndex, totalCount)
}
if (!jumped) continue
val reached = waitForStoryIndex(targetIndex)
if (!reached) continue
val uri = lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri ?: continue
previewLoading[targetIndex] = true
val bitmap = withContext(Dispatchers.IO) { loadStoryPreviewBitmap(uri) }
previewLoading[targetIndex] = false
if (bitmap != null) {
previewBitmaps[targetIndex] = bitmap
synchronized(storyPreviewCache) {
storyPreviewCache.getOrPut(previewCacheKey) { mutableMapOf() }[targetIndex] = bitmap
}
}
}
} finally {
withContext(Dispatchers.Main) {
overlay.requestJumpToSnap(currentIndex, totalCount)
}
}
}
PurrfectGlassCard(
@@ -280,7 +352,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Checkbox(
checked = selected.contains(index),
@@ -289,6 +362,35 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
},
colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)
)
Box(
modifier = Modifier
.size(54.dp)
.background(Color.White.copy(alpha = 0.06f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
val rowBitmap = previewBitmaps[index]
val rowLoading = previewLoading[index] == true
when {
rowBitmap != null -> Image(
bitmap = rowBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.size(54.dp)
.background(Color.Transparent, RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
)
rowLoading -> CircularProgressIndicator(
color = PurrfectOverlayPalette.glowPrimary,
modifier = Modifier.size(22.dp),
strokeWidth = 2.dp
)
else -> Icon(
imageVector = Icons.Outlined.Image,
contentDescription = null,
tint = PurrfectOverlayPalette.textSecondary
)
}
}
Text(
label,
style = MaterialTheme.typography.bodyMedium,
@@ -355,6 +457,49 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
}
}
private suspend fun waitForStoryIndex(targetIndex: Int, timeoutMs: Long = 3000L): Boolean {
val startedAt = System.currentTimeMillis()
while (System.currentTimeMillis() - startedAt < timeoutMs) {
if (lastSeenMapParams?.getStorySnapIndex() == targetIndex) return true
kotlinx.coroutines.delay(60L)
}
return false
}
private fun loadStoryPreviewBitmap(uriString: String): Bitmap? {
return runCatching {
val uri = Uri.parse(uriString)
when (uri.scheme?.lowercase()) {
"content" -> context.androidContext.contentResolver.openInputStream(uri)?.use(BitmapFactory::decodeStream)
"file", null -> BitmapFactory.decodeFile(uri.path)
"http", "https" -> {
runCatching {
OkHttpClient().newCall(Request.Builder().url(uriString).build()).execute().use { response ->
response.body?.byteStream()?.use { stream -> BitmapFactory.decodeStream(stream) }
}
}.getOrNull() ?: run {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(uriString, emptyMap())
retriever.frameAtTime
} finally {
runCatching { retriever.release() }
}
}
}
else -> null
} ?: run {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(context.androidContext, uri)
retriever.frameAtTime
} finally {
runCatching { retriever.release() }
}
}
}.getOrNull()
}
private fun startBatchDownload(indices: MutableList<Int>, allowDuplicate: Boolean) {
if (indices.isEmpty()) return
val paramMap = lastSeenMapParams ?: return

View File

@@ -9,6 +9,11 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.PowerManager
import android.app.ActivityManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import kotlinx.coroutines.isActive
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -26,16 +31,22 @@ import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.random.Random
import me.eternal.purrfectsnap.bridge.AutoOpenInterface
import com.google.gson.Gson
class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.AUTO_OPEN_SNAPS) {
companion object {
const val ACTION_PAUSE_RESUME = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_PAUSE_RESUME"
const val ACTION_CLEAR_QUEUE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_CLEAR_QUEUE"
}
private val gson = Gson()
data class SnapQueueItem(
val conversationId: String,
val messageId: Long,
@@ -45,18 +56,38 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
val timestamp: Long = System.currentTimeMillis()
)
private val autoOpenInterface = object : AutoOpenInterface.Stub() {
override fun getProcessedCount(): Int = totalProcessed
override fun getQueueItems(): List<String> {
return synchronized(queuedSnaps) {
queuedSnaps.map { gson.toJson(it) }
}
}
override fun reset() {
synchronized(queuedSnaps) {
queuedSnaps.clear()
}
totalProcessed = 0
updateStatusNotification()
}
}
fun getInterface(): AutoOpenInterface = autoOpenInterface
private val snapQueue = MutableSharedFlow<SnapQueueItem>()
private var snapQueueSize = AtomicInteger(0)
private val openedSnaps = mutableListOf<Long>()
private val openedSnaps = ArrayDeque<Long>()
private val isPaused = AtomicBoolean(false)
private val queuedSnaps = mutableListOf<SnapQueueItem>()
private var totalProcessed = AtomicInteger(0)
val queuedSnaps = mutableListOf<SnapQueueItem>()
var totalProcessed = 0
private set
private var sessionStartTime = System.currentTimeMillis()
var sessionStartTime = System.currentTimeMillis()
private set
private var lastResetTime = System.currentTimeMillis()
private var currentBatchSize = AtomicInteger(0)
private var currentBatchProcessed = AtomicInteger(0)
private var currentBatchSize = 0
private var currentBatchProcessed = 0
private var batchSnapCount = 0 // For jitter batch cooldown
private val config by lazy { context.config.messaging.autoOpenSnaps }
@@ -92,20 +123,20 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
val feedbackContent = if (wasPaused) {
this@AutoOpenSnaps.context.translation["auto_open_snaps.resumed_message"]
} else {
this@AutoOpenSnaps.context.translation["auto_open_snaps.paused_message"].replace("{count}", snapQueueSize.get().toString())
this@AutoOpenSnaps.context.translation["auto_open_snaps.paused_message"].replace("{count}", synchronized(queuedSnaps) { queuedSnaps.size }.toString())
}
showTemporaryNotification(feedbackTitle, feedbackContent)
updateStatusNotification()
if (wasPaused && snapQueueSize.get() > 0) {
this@AutoOpenSnaps.context.log.debug("Resumed with ${snapQueueSize.get()} snaps in queue")
if (wasPaused && synchronized(queuedSnaps) { queuedSnaps.size } > 0) {
this@AutoOpenSnaps.context.log.debug("[AUTO-OPEN] Resumed with ${synchronized(queuedSnaps) { queuedSnaps.size }} snaps in queue")
}
}
ACTION_CLEAR_QUEUE -> {
val queueSize = snapQueueSize.get()
val processedCount = totalProcessed.get()
val queueSize = synchronized(queuedSnaps) { queuedSnaps.size }
val processedCount = totalProcessed
clearQueue(resetTotalCount = true, showNotification = false)
@@ -129,26 +160,25 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
notificationManager.createNotificationChannel(
NotificationChannel(baseChannelId,
context.translation["auto_open_snaps.title"],
NotificationManager.IMPORTANCE_LOW).apply {
// Visible Presence Fix: Upgrade importance to DEFAULT so it stays in status bar.
NotificationManager.IMPORTANCE_DEFAULT).apply {
description = context.translation["auto_open_snaps.channel_description"]
setShowBadge(false)
setShowBadge(true)
setSound(null, null)
enableVibration(false)
}
)
notificationManager.createNotificationChannel(
NotificationChannel(priorityChannelId,
context.translation["auto_open_snaps.priority_title"],
NotificationManager.IMPORTANCE_DEFAULT).apply {
NotificationManager.IMPORTANCE_HIGH).apply {
description = context.translation["auto_open_snaps.priority_channel_description"]
setShowBadge(true)
setSound(null, null)
enableVibration(false)
}
)
}
private fun createPendingIntent(action: String): PendingIntent {
@@ -165,12 +195,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
private fun verifyQueueSync(): Boolean {
return synchronized(queuedSnaps) {
val actualSize = queuedSnaps.size
val atomicSize = snapQueueSize.get()
val currentTime = System.currentTimeMillis()
val timeoutMs = 5 * 60 * 1000L
val originalSize = queuedSnaps.size
val removed = mutableListOf<Long>()
queuedSnaps.removeAll { item ->
@@ -180,23 +206,14 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
if (removed.isNotEmpty()) {
context.log.warn("Cleaned up ${removed.size} stuck items")
context.log.warn("[AUTO-OPEN] Cleaned up ${removed.size} stuck items")
}
if (actualSize != atomicSize) {
context.log.warn("Queue size mismatch! Actual: $actualSize, Atomic: $atomicSize")
snapQueueSize.set(queuedSnaps.size)
val uniqueItems = queuedSnaps.distinctBy { it.messageId }.toMutableList()
if (uniqueItems.size != queuedSnaps.size) {
context.log.warn("Found ${queuedSnaps.size - uniqueItems.size} duplicate items")
queuedSnaps.clear()
queuedSnaps.addAll(uniqueItems)
snapQueueSize.set(queuedSnaps.size)
}
return@synchronized false
} else {
snapQueueSize.set(queuedSnaps.size)
val uniqueItems = queuedSnaps.distinctBy { it.messageId }.toMutableList()
if (uniqueItems.size != queuedSnaps.size) {
context.log.warn("[AUTO-OPEN] Found ${queuedSnaps.size - uniqueItems.size} duplicate items")
queuedSnaps.clear()
queuedSnaps.addAll(uniqueItems)
}
return@synchronized true
@@ -222,15 +239,25 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
lastNotificationUpdate.set(currentTime)
updateStatusNotificationInternal()
}
private fun updateStatusNotificationInternal() {
verifyQueueSync()
val queueCount = snapQueueSize.get()
val processed = totalProcessed.get()
val queueCount = synchronized(queuedSnaps) { queuedSnaps.size }
val processed = totalProcessed
if (queueCount <= 0 && processed <= 0) {
notificationManager.cancel(statusNotificationId)
// Self-Cleaning Logic: If work is done, wait 10s then auto-clear.
if (queueCount <= 0) {
if (processed > 0) {
context.coroutineScope.launch {
delay(10000)
if (synchronized(queuedSnaps) { queuedSnaps.size } <= 0) {
notificationManager.cancel(statusNotificationId)
}
}
} else {
notificationManager.cancel(statusNotificationId)
}
return
}
@@ -258,10 +285,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
.setContentText(statusText)
if (queueCount > 0) {
val batchSize = currentBatchSize.get()
val batchProcessed = currentBatchProcessed.get()
val progressMax = maxOf(batchSize, queueCount + batchProcessed)
val progressCurrent = batchProcessed
val progressMax = maxOf(currentBatchSize, queueCount + currentBatchProcessed)
val progressCurrent = currentBatchProcessed
notificationBuilder.setProgress(progressMax, progressCurrent, false)
@@ -299,7 +324,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
)
}
val recentSnaps = queuedSnaps.takeLast(5)
if (config.compactNotification.get()) {
notificationManager.notify(statusNotificationId, notificationBuilder.build())
return
}
val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) }
val bigTextStyle = Notification.BigTextStyle()
val detailText = buildString {
@@ -352,7 +382,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
val clearedCount = synchronized(queuedSnaps) {
val count = queuedSnaps.size
queuedSnaps.clear()
snapQueueSize.set(0)
count
}
@@ -361,12 +390,12 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
if (resetTotalCount) {
totalProcessed.set(0)
totalProcessed = 0
lastResetTime = System.currentTimeMillis()
}
currentBatchSize.set(0)
currentBatchProcessed.set(0)
currentBatchSize = 0
currentBatchProcessed = 0
verifyQueueSync()
@@ -376,7 +405,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
val message = if (resetTotalCount) {
context.translation["auto_open_snaps.queue_cleared"]
} else {
context.translation["auto_open_snaps.notification_queue_cleared_opened"].replace("{opened}", totalProcessed.get().toString())
context.translation["auto_open_snaps.notification_queue_cleared_opened"].replace("{opened}", totalProcessed.toString())
}
showTemporaryNotification(context.translation["auto_open_snaps.queue_cleared_title"], message)
}
@@ -488,7 +517,6 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
synchronized(queuedSnaps) {
if (!queuedSnaps.any { it.messageId == snapItem.messageId }) {
queuedSnaps.add(snapItem)
snapQueueSize.set(queuedSnaps.size)
wasAddedToPausedQueue = true
updateStatusNotification()
}
@@ -497,26 +525,63 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
delay(2000)
}
val queueSizeAfterRemoval = synchronized(queuedSnaps) {
synchronized(queuedSnaps) {
queuedSnaps.removeAll { it.messageId == snapItem.messageId }
snapQueueSize.set(queuedSnaps.size)
queuedSnaps.size
}
val minDelayMs = config.minDelay.get().toLong()
val maxDelayMs = config.maxDelayMs.get().toLong()
val delayMs = if (maxDelayMs > minDelayMs) {
Random.nextLong(minDelayMs, maxDelayMs)
} else {
minDelayMs
// RESOURCE AWARENESS
val connectivityManager = context.androidContext.getSystemService(ConnectivityManager::class.java)
val isWifi = connectivityManager?.activeNetwork?.let {
connectivityManager.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} == true
val powerManager = context.androidContext.getSystemService(PowerManager::class.java)
val isIdle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) powerManager?.isDeviceIdleMode == true else false
val activityManager = context.androidContext.getSystemService(ActivityManager::class.java)
val isGaming = activityManager?.runningAppProcesses?.firstOrNull {
it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
}?.processName?.let { name ->
!name.contains("snapchat") && !name.contains("purrfectsnap")
} ?: false
// Immediate logging for visibility in [RESOURCE] filter
context.log.info("[RESOURCE] AutoOpen: Processing snap from ${snapItem.senderInfo}. Current state: WiFi=$isWifi, Idle=$isIdle, Gaming=$isGaming")
while (
(config.onlyOnWifi.get() && !isWifi) ||
(config.onlyWhenIdle.get() && !isIdle) ||
(config.pauseDuringGaming.get() && isGaming)
) {
val waitTime = if (isGaming) 60000L else 5000L
context.log.warn("[RESOURCE] AutoOpen: Throttling queue due to resource constraints. Waiting ${waitTime}ms")
delay(waitTime)
if (!kotlin.coroutines.coroutineContext.isActive) return@collect
}
delay(delayMs)
// Stealth Pacing: Apply variable delays to remain undetected
if (config.safeProcessing.get()) {
batchSnapCount++
val isRollingCooldown = batchSnapCount % 10 == 0
val minDelayMs = if (isRollingCooldown) 5000L else config.minDelay.get().toLong()
val maxDelayMs = if (isRollingCooldown) 10000L else config.maxDelayMs.get().toLong()
val jitter = if (maxDelayMs > minDelayMs) {
java.util.concurrent.ThreadLocalRandom.current().nextLong(minDelayMs, maxDelayMs)
} else minDelayMs
context.log.verbose("[AUTO-OPEN] Stealth Pacing active. Waiting ${jitter}ms")
delay(jitter)
} else {
context.log.verbose("[AUTO-OPEN] Stealth Pacing disabled. Executing at maximum speed.")
}
var result: String? = null
var lastError = ""
for (i in 0 until config.retryAttempts.get()) {
while ((!config.allowRunningInBackground.get() && context.isMainActivityPaused) || messaging.conversationManager == null) {
delay(2000)
delay(1000)
}
result = suspendCoroutine { continuation ->
@@ -538,17 +603,16 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
if (result == null || result == "DUPLICATEREQUEST") {
totalProcessed.incrementAndGet()
currentBatchProcessed.incrementAndGet()
totalProcessed++
currentBatchProcessed++
context.log.verbose("[AUTO-OPEN] Successfully opened ${snapItem.contentType} from ${snapItem.senderInfo}")
} else {
context.log.error("Failed to open ${snapItem.contentType} from ${snapItem.senderInfo}: $lastError")
context.log.error("[AUTO-OPEN] Failed to open ${snapItem.contentType} from ${snapItem.senderInfo}: $lastError")
}
val finalQueueSize = snapQueueSize.get()
if (finalQueueSize <= 0) {
currentBatchSize.set(0)
currentBatchProcessed.set(0)
if (synchronized(queuedSnaps) { queuedSnaps.size } <= 0) {
currentBatchSize = 0
currentBatchProcessed = 0
}
updateStatusNotification()
@@ -576,7 +640,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
if (openedSnaps.contains(clientMessageId)) {
return@launch
}
openedSnaps.add(clientMessageId)
if (openedSnaps.size >= 500) openedSnaps.removeFirst()
openedSnaps.addLast(clientMessageId)
}
val senderId = event.message.senderId?.toString() ?: context.translation["auto_open_snaps.unknown_sender"]
@@ -592,7 +657,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
contentType = contentType
)
val actualQueueSize = synchronized(queuedSnaps) {
synchronized(queuedSnaps) {
val existingItem = queuedSnaps.find { it.messageId == snapItem.messageId }
if (existingItem != null) {
return@launch
@@ -603,12 +668,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
queuedSnaps.add(snapItem)
snapQueueSize.set(queuedSnaps.size)
val newSize = queuedSnaps.size
currentBatchSize.set(maxOf(currentBatchSize.get(), newSize))
queuedSnaps.size
currentBatchSize = maxOf(currentBatchSize, queuedSnaps.size)
}
updateStatusNotification()
@@ -618,6 +678,23 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
}
}
override fun onBridgeAction(action: String, extras: Map<String, Any>?, callback: (Any?) -> Unit) {
if (action == "get_auto_open_status") {
val status = mutableMapOf<String, Any>()
status["processed"] = totalProcessed
status["queue"] = synchronized(queuedSnaps) {
queuedSnaps.map { item ->
mapOf(
"senderInfo" to item.senderInfo,
"contentType" to item.contentType,
"conversationType" to item.conversationType
)
}
}
callback(status)
}
}
private fun getSenderDisplayName(senderId: String): String {
return try {
val friendInfo = context.database.getFriendInfo(senderId)

View File

@@ -161,6 +161,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 networkOptimization by context.config.experimental.networkOptimization
val spoofAndroidId by context.config.experimental.spoof.spoofDeviceId.spoofAndroidId
if(overridePlayStoreInstallerPackageName) {
@@ -270,6 +271,20 @@ class DeviceSpooferHook: Feature("Device Spoofer") {
}
}
if (networkOptimization) {
// Internal Buffer Optimization
findClass("java.net.Socket").apply {
hook("setSendBufferSize", HookStage.BEFORE) { param ->
val size = param.arg<Int>(0)
if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) // Force 1MB Buffer
}
hook("setReceiveBufferSize", HookStage.BEFORE) { param ->
val size = param.arg<Int>(0)
if (size < 1024 * 1024) param.setArg(0, 1024 * 1024) // Force 1MB Buffer
}
}
}
if (spoofAndroidId) {
val gsfId = getRandomGsfId()
val wifiMac = generateRandomMacAddress()

View File

@@ -8,13 +8,20 @@ import android.graphics.drawable.shapes.Shape
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessageState
@@ -29,9 +36,10 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.event.events.impl.*
import me.eternal.purrfectsnap.core.features.MessagingRuleFeature
import me.eternal.purrfectsnap.core.features.impl.ui.ConversationToolbox
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.ui.addForegroundDrawable
import me.eternal.purrfectsnap.core.ui.findParent
import me.eternal.purrfectsnap.core.ui.removeForegroundDrawable
import me.eternal.purrfectsnap.core.util.EvictingMap
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -185,6 +193,16 @@ class EndToEndEncryption : MessagingRuleFeature(
}
}
private fun resolveKeyActionContainer(startView: View): ViewGroup? {
val ancestors = generateSequence(startView) { current ->
current.parent as? View
}.filterIsInstance<ViewGroup>().toList()
return ancestors.firstOrNull { candidate ->
candidate is LinearLayout && candidate.orientation == LinearLayout.VERTICAL
} ?: ancestors.firstOrNull()
}
@SuppressLint("SetTextI18n", "DiscouragedApi")
override fun init() {
if (!isEnabled) return
@@ -231,30 +249,34 @@ class EndToEndEncryption : MessagingRuleFeature(
}
onNextActivityCreate(defer = true) {
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
context.database.getDMOtherParticipant(it) != null
}) { dialog, conversationId ->
val friendId = remember {
context.database.getDMOtherParticipant(conversationId)
} ?: return@addComposable
val fingerprint = remember {
runCatching {
e2eeInterface.getSecretFingerprint(friendId)
}.getOrNull()
}
if (fingerprint != null) {
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
} else {
Text(translation["toolbox.no_shared_key"])
}
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
dialog.dismiss()
warnKeyOverwrite(friendId) {
askForKeys(conversationId)
val hideConversationToolboxUi by context.config.experimental.e2eEncryption.hideConversationToolboxUi
if (!hideConversationToolboxUi) {
context.feature(ConversationToolbox::class).addComposable(translation["confirmation_dialogs.title"], filter = {
context.database.getDMOtherParticipant(it) != null
}) { dialog, conversationId ->
val friendId = remember {
context.database.getDMOtherParticipant(conversationId)
} ?: return@addComposable
val fingerprint = remember {
runCatching {
e2eeInterface.getSecretFingerprint(friendId)
}.getOrNull()
}
if (fingerprint != null) {
Text(translation.format("toolbox.shared_key_fingerprint", "fingerprint" to fingerprint))
} else {
Text(translation["toolbox.no_shared_key"])
}
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
dialog.dismiss()
warnKeyOverwrite(friendId) {
askForKeys(conversationId)
}
}) {
Text(translation["toolbox.initiate_exchange_button"])
}
}) {
Text(translation["toolbox.initiate_exchange_button"])
}
}
@@ -264,9 +286,7 @@ class EndToEndEncryption : MessagingRuleFeature(
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { conversationId, messageId ->
val viewGroup = event.view.findParent(maxIteration = 3) {
it is LinearLayout
} as? ViewGroup ?: event.view.parent as? ViewGroup ?: return@chatMessage
val viewGroup = resolveKeyActionContainer(event.view) ?: return@chatMessage
viewGroup.findViewWithTag<View>(specialCard)?.also {
viewGroup.removeView(it)
@@ -289,27 +309,45 @@ class EndToEndEncryption : MessagingRuleFeature(
val publicKey = pkRequests[messageId.toLong()]
if (publicKey != null || secret != null) {
viewGroup.addView(createComposeView(context.mainActivity!!) {
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
onClick = {
if (publicKey != null) {
handlePublicKeyRequest(conversationId, publicKey)
}
if (secret != null) {
handleSecretResponse(conversationId, secret)
}
}
) {
createComposeView(viewGroup.context) {
PurrfectOverlayTheme {
val actionShape = RoundedCornerShape(22.dp)
val borderBrush = Brush.linearGradient(
listOf(
PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.70f),
PurrfectOverlayPalette.glowSecondary.copy(alpha = 0.55f),
)
)
Box(
modifier = Modifier.fillMaxWidth().padding(5.dp),
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp, bottom = 6.dp),
contentAlignment = Alignment.Center
) {
if (publicKey != null) {
Text(translation["accept_public_key_button"])
}
if (secret != null) {
Text(translation["accept_secret_button"])
Box(
modifier = Modifier
.clip(actionShape)
.background(PurrfectOverlayPalette.cardOverlay, actionShape)
.border(1.15.dp, borderBrush, actionShape)
.padding(horizontal = 18.dp, vertical = 11.dp)
) {
if (publicKey != null) {
Text(
text = translation["accept_public_key_button"],
color = PurrfectOverlayPalette.textPrimary,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold
)
}
if (secret != null) {
Text(
text = translation["accept_secret_button"],
color = PurrfectOverlayPalette.textPrimary,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
@@ -319,7 +357,16 @@ class EndToEndEncryption : MessagingRuleFeature(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
)
})
setOnClickListener {
if (publicKey != null) {
handlePublicKeyRequest(conversationId, publicKey)
}
if (secret != null) {
handleSecretResponse(conversationId, secret)
}
}
viewGroup.addView(this)
}
}
}
}
@@ -353,6 +400,7 @@ class EndToEndEncryption : MessagingRuleFeature(
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val messageContent = event.messageContent
val destinations = event.destinations
if (messageContent.contentType != ContentType.CHAT) return@subscribe
val e2eeConversations = destinations.getEndToEndConversations().takeIf { it.isNotEmpty() } ?: return@subscribe
@@ -384,10 +432,6 @@ class EndToEndEncryption : MessagingRuleFeature(
context.longToast(translation["encryption_failed_toast"])
}
}
if (event.messageContent.contentType == ContentType.SNAP) {
event.messageContent.contentType = ContentType.EXTERNAL_MEDIA
}
}
}

View File

@@ -2,12 +2,18 @@ package me.eternal.purrfectsnap.core.features.impl.experiments
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ContentUris
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Intent
import android.database.Cursor
import android.database.CursorWrapper
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.media.MediaMuxer
import android.net.Uri
import android.os.Build
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.webkit.MimeTypeMap
@@ -36,6 +42,7 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.FileType
import me.eternal.purrfectsnap.common.ui.createComposeView
@@ -47,17 +54,308 @@ import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.util.dataBuilder
import me.eternal.purrfectsnap.core.util.hook.Hooker
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.mapper.impl.ChatMediaDrawerMapper
import java.io.File
import java.io.InputStream
import java.lang.reflect.Method
import java.nio.ByteBuffer
import kotlin.random.Random
class MediaFilePicker : Feature("Media File Picker") {
companion object {
private const val SNAP_CHUNK_DURATION_MS = 10_000L
private val queuedSplitItems = ArrayDeque<Any>()
private val queuedSplitItemIds = ArrayDeque<String>()
private val queuedSplitCleanupUris = mutableMapOf<String, String>()
private var originalUnsplitItem: Any? = null
private var reusableOriginalItem: Any? = null
private var queuedOverrideType: String? = null
private var bypassSplitOnce = false
private var sendSingleItemHandler: ((Any) -> Boolean)? = null
private var cleanupItemHandler: ((String) -> Unit)? = null
fun hasQueuedSplitItems(): Boolean = queuedSplitItems.isNotEmpty()
fun hasPendingSplitCleanup(): Boolean = queuedSplitItemIds.isNotEmpty()
fun hasOriginalUnsplitItem(): Boolean = originalUnsplitItem != null
fun hasReusableOriginalItem(): Boolean = reusableOriginalItem != null
fun setQueuedOverrideType(value: String?) {
queuedOverrideType = value
}
fun getQueuedOverrideType(): String? = queuedOverrideType
fun clearQueuedSplitItems(deleteTempItems: Boolean = true) {
if (deleteTempItems) {
val cleanup = cleanupItemHandler
queuedSplitCleanupUris.values.toList().forEach { uri ->
cleanup?.invoke(uri)
}
}
queuedSplitItems.clear()
queuedSplitItemIds.clear()
queuedSplitCleanupUris.clear()
originalUnsplitItem = null
queuedOverrideType = null
}
fun sendReusableOriginalItem(): Boolean {
val item = reusableOriginalItem ?: return false
bypassSplitOnce = true
val sender = sendSingleItemHandler ?: return false
return sender(item)
}
private fun queueSplitItems(items: List<Any>, preparedItems: List<PreparedMediaItem>, originalItem: Any?) {
clearQueuedSplitItems(deleteTempItems = false)
originalUnsplitItem = originalItem
items.drop(1).forEach { queuedSplitItems.addLast(it) }
preparedItems.forEach {
queuedSplitItemIds.addLast(it.itemId)
queuedSplitCleanupUris[it.itemId] = it.uri
}
}
fun sendOriginalUnsplitItem(): Boolean {
val item = originalUnsplitItem ?: return false
val overrideType = queuedOverrideType
clearQueuedSplitItems(deleteTempItems = true)
queuedOverrideType = overrideType
bypassSplitOnce = true
val sender = sendSingleItemHandler ?: return false
return sender(item)
}
fun handleCurrentQueuedItemSuccess(): Boolean {
queuedSplitItemIds.removeFirstOrNull()?.let { itemId ->
queuedSplitCleanupUris.remove(itemId)?.let { uri ->
cleanupItemHandler?.invoke(uri)
}
}
if (queuedSplitItems.isEmpty()) {
queuedOverrideType = null
return false
}
val next = queuedSplitItems.removeFirstOrNull() ?: run {
queuedOverrideType = null
return false
}
val sender = sendSingleItemHandler ?: return false
val result = sender(next)
if (!result) {
queuedSplitItems.addFirst(next)
}
return result
}
}
var lastMediaDuration: Long? = null
private set
private data class PreparedMediaItem(
val itemId: String,
val durationMs: Long,
val uri: String
)
private fun splitVideoIntoChunks(
inputFile: File,
chunkDurationMs: Long = SNAP_CHUNK_DURATION_MS
): List<File> {
val durationMs = extractMediaDuration(Uri.fromFile(inputFile)) ?: return emptyList()
if (durationMs <= chunkDurationMs) return listOf(inputFile)
val retriever = MediaMetadataRetriever()
val rotation = runCatching {
retriever.setDataSource(inputFile.absolutePath)
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0
}.getOrDefault(0).also {
runCatching { retriever.release() }
}
val outputFiles = mutableListOf<File>()
var chunkStartMs = 0L
var chunkIndex = 0
while (chunkStartMs < durationMs) {
val chunkEndMs = minOf(chunkStartMs + chunkDurationMs, durationMs)
val outputFile = File.createTempFile("purrfectsnap_chunk_${chunkIndex}_", ".mp4", context.androidContext.cacheDir)
val extractor = MediaExtractor()
val muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val trackMap = mutableMapOf<Int, Int>()
val chunkStartUs = chunkStartMs * 1000
val chunkEndUs = chunkEndMs * 1000
var muxerStarted = false
var wroteAnySample = false
try {
extractor.setDataSource(inputFile.absolutePath)
repeat(extractor.trackCount) { trackIndex ->
val format = extractor.getTrackFormat(trackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: return@repeat
if (!mime.startsWith("video/") && !mime.startsWith("audio/")) return@repeat
extractor.selectTrack(trackIndex)
trackMap[trackIndex] = muxer.addTrack(format)
}
if (rotation != 0) {
muxer.setOrientationHint(rotation)
}
val maxBufferSize = (0 until extractor.trackCount).maxOfOrNull { trackIndex ->
extractor.getTrackFormat(trackIndex).let { format ->
if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
} else {
1024 * 1024
}
}
} ?: (1024 * 1024)
val buffer = ByteBuffer.allocateDirect(maxBufferSize)
val bufferInfo = android.media.MediaCodec.BufferInfo()
muxer.start()
muxerStarted = true
extractor.seekTo(chunkStartUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
while (true) {
bufferInfo.offset = 0
bufferInfo.size = extractor.readSampleData(buffer, 0)
if (bufferInfo.size < 0) break
val sampleTimeUs = extractor.sampleTime
if (sampleTimeUs < 0) break
if (sampleTimeUs < chunkStartUs) {
extractor.advance()
continue
}
if (sampleTimeUs >= chunkEndUs) break
val sampleTrackIndex = extractor.sampleTrackIndex
val muxerTrackIndex = trackMap[sampleTrackIndex]
if (muxerTrackIndex != null) {
bufferInfo.presentationTimeUs = sampleTimeUs - chunkStartUs
bufferInfo.flags = extractor.sampleFlags
muxer.writeSampleData(muxerTrackIndex, buffer, bufferInfo)
wroteAnySample = true
}
extractor.advance()
}
if (wroteAnySample) {
outputFiles += outputFile
} else {
outputFile.delete()
}
} catch (throwable: Throwable) {
outputFile.delete()
outputFiles.forEach { it.delete() }
throw throwable
} finally {
if (muxerStarted) {
runCatching { muxer.stop() }
}
runCatching { muxer.release() }
runCatching { extractor.release() }
}
chunkStartMs += chunkDurationMs
chunkIndex++
}
return outputFiles
}
private fun registerTemporaryVideo(file: File, displayName: String): PreparedMediaItem {
val resolver = context.androidContext.contentResolver
val values = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, displayName)
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/.PurrfectSnap")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.IS_PENDING, 1)
}
}
val uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values)
?: error("Failed to create MediaStore entry")
runCatching {
resolver.openOutputStream(uri)?.use { output ->
file.inputStream().use { input -> input.copyTo(output) }
} ?: error("Failed to open MediaStore output stream")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.update(uri, ContentValues().apply {
put(MediaStore.Video.Media.IS_PENDING, 0)
}, null, null)
}
}.onFailure {
resolver.delete(uri, null, null)
throw it
}
val durationMs = extractMediaDuration(uri) ?: 0L
val itemId = uri.lastPathSegment ?: error("Failed to resolve MediaStore item id")
context.coroutineScope.launch {
delay(120_000)
runCatching { resolver.delete(uri, null, null) }
}
return PreparedMediaItem(itemId = itemId, durationMs = durationMs, uri = uri.toString())
}
private fun buildDrawerItems(itemClass: Any, mediaItems: List<PreparedMediaItem>): List<Any> {
return mediaItems.mapIndexedNotNull { index, mediaItem ->
itemClass.dataBuilder {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", mediaItem.durationMs.toDouble())
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
set("_height", 1920.0)
set("_timestampMs", (System.currentTimeMillis() + index).toDouble())
from("_itemId") {
set("_itemId", mediaItem.itemId)
set("_type", "VIDEO")
}
}
set("_order", index.toDouble())
}
}
}
private fun prepareChunkedItemsFromMediaStoreId(itemId: String, durationMs: Long): List<PreparedMediaItem>? {
val numericId = itemId.toLongOrNull() ?: return null
val effectiveDurationMs = durationMs.takeIf { it > 0 } ?: extractMediaDuration(
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
) ?: return null
if (effectiveDurationMs <= SNAP_CHUNK_DURATION_MS) return null
val sourceUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, numericId)
val sourceFile = File.createTempFile("purrfectsnap_gallery_source_", ".mp4", context.androidContext.cacheDir)
return runCatching {
context.androidContext.contentResolver.openInputStream(sourceUri)?.use { input ->
sourceFile.outputStream().use { output -> input.copyTo(output) }
} ?: error("Failed to open source gallery video")
val chunkFiles = splitVideoIntoChunks(sourceFile, SNAP_CHUNK_DURATION_MS)
val preparedItems = chunkFiles.mapIndexed { index, file ->
registerTemporaryVideo(file, "purrfectsnap_gallery_chunk_${System.currentTimeMillis()}_$index.mp4")
}
chunkFiles.forEach { if (it != sourceFile) it.delete() }
preparedItems
}.also {
sourceFile.delete()
}.getOrElse {
context.log.error("Failed to prepare split gallery items", it)
null
}
}
private fun extractMediaDuration(uri: Uri): Long? {
val retriever = MediaMetadataRetriever()
return runCatching {
@@ -96,6 +394,7 @@ class MediaFilePicker : Feature("Media File Picker") {
var sendItemsMethod: Method? = null
var drawerViewClass: Class<*>? = null
var sendItemsListItemClassFallback: Class<*>? = null
var sendItemsHookedHandler: Any? = null
context.mappings.useMapper(ChatMediaDrawerMapper::class) {
val drawerCls = chatMediaDrawerClass.getAsClass() ?: return@useMapper
@@ -115,6 +414,72 @@ class MediaFilePicker : Feature("Media File Picker") {
sendItemsMethod = sendItems
handlerParamMethod.hook(HookStage.AFTER) {
chatMediaDrawerActionHandler = it.arg(0)
val handlerInstance = chatMediaDrawerActionHandler
sendSingleItemHandler = sendSingleItem@{ item ->
runCatching {
sendItemsMethod?.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
true
}.getOrElse { throwable ->
context.log.error("MediaFilePicker: Failed to send queued split item", throwable)
false
}
}
cleanupItemHandler = { uriString ->
runCatching {
context.androidContext.contentResolver.delete(Uri.parse(uriString), null, null)
}.onFailure {
context.log.warn("MediaFilePicker: Failed to delete temp split media: ${it.message}")
}
}
if (sendItemsHookedHandler === handlerInstance) return@hook
sendItemsHookedHandler = handlerInstance
Hooker.hookObjectMethod(
handlerInstance::class.java,
handlerInstance,
sendItemsName,
HookStage.BEFORE
) { param ->
if (bypassSplitOnce) {
bypassSplitOnce = false
return@hookObjectMethod
}
val currentItems = (param.argNullable<Any>(1) as? List<*>)?.filterNotNull() ?: return@hookObjectMethod
if (currentItems.isEmpty()) return@hookObjectMethod
reusableOriginalItem = currentItems.firstOrNull()
val itemClass = sendItems.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
?: sendItemsListItemClassFallback
?: currentItems.firstOrNull()?.javaClass
?: return@hookObjectMethod
val preparedExpandedItems = mutableListOf<PreparedMediaItem>()
var didExpand = false
val expandedItems = currentItems.flatMap { item ->
val baseItem = item.getObjectFieldOrNull("_item") ?: return@flatMap listOf(item)
val durationMs = ((baseItem.getObjectFieldOrNull("_durationMs") as? Double)?.toLong())
?: ((baseItem.getObjectFieldOrNull("_durationMs") as? Long))
?: 0L
val itemId = baseItem.getObjectFieldOrNull("_itemId")
?.getObjectFieldOrNull("_itemId")
?.toString()
?: return@flatMap listOf(item)
val splitItems = prepareChunkedItemsFromMediaStoreId(itemId, durationMs)
if (splitItems.isNullOrEmpty()) {
listOf(item)
} else {
didExpand = true
preparedExpandedItems.addAll(splitItems)
buildDrawerItems(itemClass, splitItems)
}
}
if (didExpand && expandedItems.isNotEmpty()) {
queueSplitItems(expandedItems, preparedExpandedItems, currentItems.firstOrNull())
param.setArg(1, listOf(expandedItems.first()))
}
}
}
}
@@ -171,7 +536,7 @@ class MediaFilePicker : Feature("Media File Picker") {
return@subscribe
}
fun sendMedia() {
fun sendMedia(items: List<PreparedMediaItem>? = null) {
val method = sendItemsMethod ?: return
val itemClass = method.genericParameterTypes.getOrNull(1)?.getTypeArguments()?.firstOrNull()
?: sendItemsListItemClassFallback
@@ -180,27 +545,13 @@ class MediaFilePicker : Feature("Media File Picker") {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to send media (incompatible version).")
return
}
val item = itemClass.dataBuilder {
from("_item") {
set("_cameraRollSource", "Snapchat")
set("_contentUri", "")
set("_durationMs", (lastMediaDuration ?: 0L).toDouble())
set("_disabled", false)
set("_imageRotation", 0.0)
set("_width", 1080.0)
set("_height", 1920.0)
set("_timestampMs", System.currentTimeMillis().toDouble())
from("_itemId") {
set("_itemId", firstVideoId.toString())
set("_type", "VIDEO")
}
}
set("_order", 0.0)
} ?: run {
val mediaItems = items ?: listOf(PreparedMediaItem(firstVideoId.toString(), lastMediaDuration ?: 0L, ""))
val builtItems = buildDrawerItems(itemClass, mediaItems)
if (builtItems.size != mediaItems.size) {
context.inAppOverlay.showStatusToast(Icons.Default.Error, "Failed to build media item.")
return
}
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), listOf(item))
method.invoke(chatMediaDrawerActionHandler, listOf<Any>(), builtItems)
}
fun startConversion(audioOnly: Boolean) {
@@ -238,8 +589,25 @@ class MediaFilePicker : Feature("Media File Picker") {
context.inAppOverlay.showStatusToast(Icons.Default.CheckCircleOutline, "Media converted successfully.")
runCatching {
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
sendMedia()
if (!audioOnly && (lastMediaDuration ?: 0L) > 10_000L) {
val convertedFile = File.createTempFile("purrfectsnap_source_", ".$outputExtension", context.androidContext.cacheDir)
ParcelFileDescriptor.AutoCloseInputStream(pfd).use { input ->
convertedFile.outputStream().use { output -> input.copyTo(output) }
}
val chunkFiles = splitVideoIntoChunks(convertedFile)
val preparedItems = chunkFiles.mapIndexed { index, file ->
registerTemporaryVideo(file, "purrfectsnap_chunk_${System.currentTimeMillis()}_$index.mp4")
}
chunkFiles.forEach { if (it != convertedFile) it.delete() }
convertedFile.delete()
sendMedia(preparedItems)
} else {
mediaInputStream = ParcelFileDescriptor.AutoCloseInputStream(pfd)
sendMedia()
}
}.onFailure {
mediaInputStream = null
context.log.error(it)

View File

@@ -7,10 +7,10 @@ import me.eternal.purrfectsnap.core.util.hook.hook
class DisableTelecomFramework: Feature("Disable Telecom Framework") {
override fun init() {
if (!context.config.global.disableTelecomFramework.get()) return
if (!context.config.global.disableTelecomFramework.get() && !context.config.messaging.blockCalls.get()) return
ContextWrapper::class.java.hook("getSystemService", HookStage.BEFORE) { param ->
if (param.arg<Any>(0).toString() == "telecom") param.setResult(null)
}
}
}
}

View File

@@ -1,18 +1,32 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.widget.ProgressBar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.data.MessageUpdate
import me.eternal.purrfectsnap.core.event.events.impl.OnSnapInteractionEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.spying.StealthMode
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
import me.eternal.purrfectsnap.core.util.CallbackBuilder
import me.eternal.purrfectsnap.core.util.hook.HookStage
@@ -26,6 +40,54 @@ import kotlin.random.Random
class AutoMarkAsRead : Feature("Auto Mark As Read") {
val canMarkConversationAsRead by lazy { context.config.messaging.autoMarkAsRead.get().contains("conversation_read") }
private val markAsSeenBatchSize = 50
private val markAsSeenBatchCooldownMs = 4000L
private data class PendingSnapMessage(
val clientMessageId: Long,
val creationTimestamp: Long
)
private fun String?.isRateLimited(): Boolean {
val value = this ?: return false
return value.contains("RESOURCE_EXHAUSTED", ignoreCase = true) ||
value.contains("Rate limited", ignoreCase = true)
}
private fun showRateLimitedDialog(processed: Int, total: Int) {
val activity = context.mainActivity ?: run {
context.inAppOverlay.showStatusToast(
Icons.Default.WarningAmber,
"Rate limited after $processed/$total snaps. Try again later."
)
return
}
createComposeAlertDialog(activity) {
PurrfectOverlayTheme {
PurrfectGlassCard(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
title = "Rate Limited",
subtitle = "Snapchat stopped the mark-as-seen run to protect your account",
icon = Icons.Default.WarningAmber
) {
Column(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Processed $processed of $total snaps before the request was rate limited.",
color = PurrfectOverlayPalette.textSecondary
)
Text(
text = "No bypass was attempted. Wait a bit and run it again, or lower the per-run limit in settings.",
color = PurrfectOverlayPalette.textSecondary
)
}
}
}
}.show()
}
fun markConversationsAsRead(conversationIds: List<String>) {
conversationIds.forEach { conversationId ->
@@ -50,36 +112,123 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
}
}
private fun getPendingSnapMessageIds(conversationId: String, requestedLimit: Int?): List<Long> {
val collected = mutableListOf<PendingSnapMessage>()
val pageSize = 200
var page = 0
while (true) {
val messages = context.database.getMessagesFromConversationId(conversationId, pageSize, page) ?: break
messages.forEach { message ->
if (message.contentType != ContentType.SNAP.id && message.contentType != ContentType.EXTERNAL_MEDIA.id) return@forEach
if (message.isViewedByUser == 1 || message.readTimestamp > 0L) return@forEach
collected += PendingSnapMessage(
clientMessageId = message.clientMessageId.toLong(),
creationTimestamp = message.creationTimestamp
)
}
if (messages.size < pageSize) break
if (requestedLimit != null && collected.size >= requestedLimit) break
page++
}
return collected
.distinctBy { it.clientMessageId }
.sortedBy { it.creationTimestamp }
.let { pending ->
if (requestedLimit != null) pending.take(requestedLimit) else pending
}
.map { it.clientMessageId }
}
fun markSnapsAsSeen(conversationId: String) {
val messaging = context.feature(Messaging::class)
val messageIds = messaging.getFeedCachedMessageIds(conversationId)?.takeIf { it.isNotEmpty() } ?: run {
context.inAppOverlay.showStatusToast(
Icons.Default.WarningAmber,
context.translation["mark_as_seen.no_unseen_snaps_toast"]
)
return
val processingMode = context.config.messaging.markSnapAsSeenProcessingMode.get()
val configuredLimit = context.config.messaging.markSnapAsSeenLimit.get()
.coerceAtLeast(1)
val requestedLimit = if (processingMode == "complete") null else configuredLimit
val messageIds = getPendingSnapMessageIds(conversationId, requestedLimit)
.ifEmpty {
messaging.getFeedCachedMessageIds(conversationId)
?.map { it.toLong() }
?.takeIf { it.isNotEmpty() }
?.let { cached ->
if (requestedLimit != null) cached.take(requestedLimit) else cached
}
?: run {
context.inAppOverlay.showStatusToast(
Icons.Default.WarningAmber,
context.translation["mark_as_seen.no_unseen_snaps_toast"]
)
return
}
}
val targetMessageIds = if (requestedLimit == null) {
messageIds
} else {
messageIds.take(requestedLimit)
}
var job: Job? = null
val dialog = ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle("Processing...")
.setView(ProgressBar(context.mainActivity).apply {
setPadding(10, 10, 10, 10)
})
.setOnDismissListener { job?.cancel() }
.show()
val processedCount = mutableIntStateOf(0)
var rateLimitedAt: Int? = null
val dialog = createComposeAlertDialog(context.mainActivity!!, builder = {
setOnDismissListener { job?.cancel() }
}) {
PurrfectOverlayTheme {
PurrfectGlassCard(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
title = "Marking Snaps as Seen",
subtitle = "Updating read state for queued snaps",
icon = Icons.Default.Visibility
) {
Column(
modifier = androidx.compose.ui.Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = PurrfectOverlayPalette.glowSecondary,
trackColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.18f)
)
Text(
text = "${processedCount.intValue}/${targetMessageIds.size}",
color = PurrfectOverlayPalette.textSecondary
)
}
}
}
}.apply { show() }
context.coroutineScope.launch(Dispatchers.IO) {
messageIds.forEach { messageId ->
markSnapAsSeen(conversationId, messageId)
targetMessageIds.forEachIndexed { index, messageId ->
val result = markSnapAsSeen(conversationId, messageId)
if (result.isRateLimited()) {
rateLimitedAt = processedCount.intValue
return@launch
}
delay(Random.nextLong(20, 60))
context.runOnUiThread {
dialog.setTitle("Processing... (${messageIds.indexOf(messageId) + 1}/${messageIds.size})")
processedCount.intValue = index + 1
}
val processed = index + 1
if (processed < targetMessageIds.size && processed % markAsSeenBatchSize == 0) {
delay(markAsSeenBatchCooldownMs)
}
}
}.also { job = it }.invokeOnCompletion {
context.runOnUiThread {
dialog.dismiss()
if (rateLimitedAt != null) {
val processedIndex = rateLimitedAt!!
processedCount.intValue = processedIndex
showRateLimitedDialog(processedIndex, targetMessageIds.size)
} else if (requestedLimit != null && targetMessageIds.size < messageIds.size) {
context.inAppOverlay.showStatusToast(
Icons.Default.Info,
"Processed ${targetMessageIds.size} of ${messageIds.size} unseen snaps."
)
}
}
}
}
@@ -153,4 +302,4 @@ class AutoMarkAsRead : Feature("Auto Mark As Read") {
}
}
}
}
}

View File

@@ -0,0 +1,171 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.Notification
import android.app.NotificationManager
import android.content.Intent
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.findRestrictedMethod
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import java.nio.ByteBuffer
class BlockCalls : Feature("Block Calls") {
private fun isBlockedCallNotificationType(type: String?): Boolean {
return type?.lowercase() in setOf(
"initiate_audio",
"initiate_video",
"abandon_audio",
"abandon_video"
)
}
private fun shouldBlockVolatilePayload(eventData: ProtoReader): Boolean {
val dump = eventData.toString().lowercase()
return listOf(
"\"calluuid\"",
"\"callaction\"",
"\"messagetype\":\"caller_push\"",
"\"messagetype\":\"streamer_data_v2\"",
"\"messagetype\":\"callee_push\"",
"\"messagetype\":\"caller_hangup\"",
"\"messagetype\":\"call_end\""
).any { it in dump }
}
override fun init() {
if (!context.config.messaging.blockCalls.get()) return
runCatching {
findClass("com.google.firebase.messaging.FirebaseMessagingService")
.methods
.first {
it.declaringClass.name == "com.google.firebase.messaging.FirebaseMessagingService" &&
it.returnType == Void::class.javaPrimitiveType &&
it.parameterCount == 1 &&
it.parameterTypes[0] == Intent::class.java
}
.hook(HookStage.BEFORE) { param ->
val intent = param.argNullable<Intent>(0) ?: return@hook
if (!isBlockedCallNotificationType(intent.getStringExtra("type"))) return@hook
context.log.verbose("Blocked Firebase call message ${intent.getStringExtra("type")}", "BlockCalls")
param.setResult(null)
}
}
runCatching {
NotificationManager::class.java.findRestrictedMethod { it.name == "notifyAsUser" }?.hook(HookStage.BEFORE) { param ->
val notification = param.argNullable<Notification>(2) ?: return@hook
val notificationType = notification.extras
?.getBundle("system_notification_extras")
?.getString("notification_type")
if (!isBlockedCallNotificationType(notificationType)) return@hook
context.log.verbose("Blocked call notification $notificationType", "BlockCalls")
param.setResult(null)
}
}
runCatching {
findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").hook("onReceive", HookStage.BEFORE) { param ->
val buffer = param.argNullable<ByteBuffer>(0) ?: return@hook
val duplicate = buffer.duplicate().apply { position(0) }
val bytes = ByteArray(duplicate.limit())
duplicate.get(bytes)
val reader = ProtoReader(bytes)
val eventType = reader.getString(1, 1) ?: return@hook
if (eventType != "volatile") return@hook
val eventData = reader.followPath(1, 2) ?: return@hook
if (!shouldBlockVolatilePayload(eventData)) return@hook
context.log.verbose("Blocked volatile call payload", "BlockCalls")
param.setResult(null)
}
}
val talkCoreNames = listOf(
"com.snapchat.talkcorev3.TalkCore\$CppProxy",
"com.snapchat.talkcorev4.TalkCore\$CppProxy",
"com.snapchat.talkcore.TalkCore\$CppProxy"
)
talkCoreNames.forEach { className ->
runCatching {
findClass(className).apply {
hook("updateTSCallingSession", HookStage.BEFORE) { param ->
val params = param.argNullable<Any>(0)
val conversationId = params?.getObjectFieldOrNull("mConversationId")?.toString()
val inCall = params?.getObjectFieldOrNull("mInCall") as? Boolean
context.log.verbose(
"Blocked talk session update inCall=$inCall convo=$conversationId",
"BlockCalls"
)
param.setResult(null)
}
hook("disposeTSCallingSession", HookStage.BEFORE) { param ->
context.log.verbose("Blocked talk session dispose", "BlockCalls")
param.setResult(null)
}
}
}
}
listOf(
"com.snapchat.talkcorev3.TSCallingStateUpdateParams",
"com.snapchat.talkcorev4.TSCallingStateUpdateParams",
"com.snapchat.talkcore.TSCallingStateUpdateParams"
).forEach { className ->
runCatching {
findClass(className).hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val inCall = instance.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hookConstructor
if (!inCall) return@hookConstructor
instance.setObjectField("mInCall", false)
context.log.verbose("Forced TSCallingStateUpdateParams.mInCall=false", "BlockCalls")
}
}
}
context.event.subscribe(AddViewEvent::class) { event ->
val viewName = event.viewClassName.lowercase()
val parentName = event.parent.javaClass.name.lowercase()
val exactCallUi = setOf(
"com.snap.talk.callviewwrapper",
"com.snap.talk.core.callcontainer"
)
val callUiParents = setOf(
"com.snap.talk.core.callcontainer"
)
if (viewName in exactCallUi || parentName in callUiParents) {
context.log.verbose(
"Suppressed view ${event.viewClassName} parent=${event.parent.javaClass.name}",
"BlockCalls"
)
event.view.hideViewCompletely()
return@subscribe
}
if (viewName.endsWith("callbuttonsview") ||
(viewName.contains("call") && (
viewName.contains("overlay") ||
viewName.contains("incoming") ||
viewName.contains("ringing") ||
viewName.contains("ringer")
))
) {
event.view.hideViewCompletely()
}
}
}
}

View File

@@ -3,8 +3,28 @@ package me.eternal.purrfectsnap.core.features.impl.messaging
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
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
import me.eternal.purrfectsnap.core.ui.children
import me.eternal.purrfectsnap.core.ui.hideViewCompletely
@@ -16,22 +36,57 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
private fun hookTouchEvent(param: HookAdapter, motionEvent: MotionEvent, onConfirm: () -> Unit) {
if (motionEvent.action != MotionEvent.ACTION_UP) return
param.setResult(true)
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)
.setTitle(context.translation["call_start_confirmation.dialog_title"])
.setMessage(context.translation["call_start_confirmation.dialog_message"])
.setPositiveButton(context.translation["button.positive"]) { _, _ -> onConfirm() }
.setNeutralButton(context.translation["button.negative"]) { _, _ -> }
.show()
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
PurrfectGlassCard(
modifier = Modifier.fillMaxWidth(),
title = context.translation["call_start_confirmation.dialog_title"],
subtitle = context.translation["call_start_confirmation.dialog_message"],
icon = Icons.Default.Call
) {
val actionShape = RoundedCornerShape(16.dp)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp, androidx.compose.ui.Alignment.CenterHorizontally)
) {
Button(
modifier = Modifier.width(120.dp),
onClick = { alertDialog.dismiss() },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(context.translation["button.negative"])
}
Button(
modifier = Modifier.width(120.dp),
onClick = {
alertDialog.dismiss()
onConfirm()
},
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.26f),
contentColor = Color.White
)
) {
Text(context.translation["button.positive"])
}
}
}
}
}.show()
}
override fun init() {
val hideUiComponents by context.config.userInterface.hideUiComponents
val blockCalls = context.config.messaging.blockCalls.get()
val hideProfileCallButtons = hideUiComponents.contains("hide_profile_call_buttons")
val hideChatCallButtons = hideUiComponents.contains("hide_chat_call_buttons")
val hideProfileCallButtons = blockCalls || hideUiComponents.contains("hide_profile_call_buttons")
val hideChatCallButtons = blockCalls || hideUiComponents.contains("hide_chat_call_buttons")
val callStartConfirmation = context.config.messaging.callStartConfirmation.get()
if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation) return
if (!hideProfileCallButtons && !hideChatCallButtons && !callStartConfirmation && !blockCalls) return
var actionSheetVideoCallButtonId = -1
var actionSheetAudioCallButtonId = -1
@@ -57,7 +112,7 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
}
onNextActivityCreate {
if (callStartConfirmation) {
if (callStartConfirmation || blockCalls) {
(runCatching { findClass("com.snap.valdi.views.ValdiRootView") }.getOrNull()
?: findClass("com.snap.composer.views.ComposerRootView"))
.hook("dispatchTouchEvent", HookStage.BEFORE) { param ->
@@ -68,6 +123,10 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
if (childComposerView.children().count {
it::class.java == childComposerView::class.java
} != 2) return@hook
if (blockCalls) {
param.setResult(true)
return@hook
}
hookTouchEvent(param, param.arg(0)) {
param.invokeOriginal()
}
@@ -77,6 +136,10 @@ class CallButtonsOverride : Feature("CallButtonsOverride") {
val view = param.thisObject<View>().takeIf { it.id != -1 } ?: return@hook
if (view.id != actionSheetAudioCallButtonId && view.id != actionSheetVideoCallButtonId) return@hook
if (blockCalls) {
param.setResult(true)
return@hook
}
hookTouchEvent(param, param.arg(0)) {
arrayOf(
MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0f, 0f, 0),

View File

@@ -0,0 +1,274 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.os.Build
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import java.nio.ByteBuffer
import java.text.DateFormat
import java.util.Date
import java.util.LinkedHashSet
import java.util.Locale
class CallMetadataNotifier : Feature("Call Metadata Notifier") {
private data class CapturedCallMetadata(
var callUuid: String? = null,
var attemptId: String? = null,
var media: String? = null,
var isGroup: String? = null,
var scopeId: String? = null,
var ipv4Address: String? = null,
var startTimestamp: Long? = null,
var endedTimestamp: Long? = null,
val messageTypes: LinkedHashSet<String> = linkedSetOf(),
val rawPayloads: LinkedHashSet<String> = linkedSetOf()
) {
fun reset() {
callUuid = null
attemptId = null
media = null
isGroup = null
scopeId = null
ipv4Address = null
startTimestamp = null
endedTimestamp = null
messageTypes.clear()
rawPayloads.clear()
}
fun hasData(): Boolean {
return callUuid != null ||
attemptId != null ||
media != null ||
isGroup != null ||
scopeId != null ||
ipv4Address != null ||
messageTypes.isNotEmpty() ||
rawPayloads.isNotEmpty()
}
}
private val notificationChannelId = "call_metadata_notifier"
private val metadata = CapturedCallMetadata()
private var wasInCall = false
private val translation by lazy { context.translation.getCategory("call_metadata_notifier") }
private val notificationManager by lazy {
context.androidContext.getSystemService(NotificationManager::class.java).apply {
createNotificationChannel(
NotificationChannel(
notificationChannelId,
translation["notification_channel_name"],
NotificationManager.IMPORTANCE_HIGH
)
)
}
}
private fun isAscii(bytes: ByteArray): Boolean {
return bytes.all { it in 0x20..0x7E || it == 0x0A.toByte() || it == 0x0D.toByte() }
}
private fun collectStrings(reader: ProtoReader, depth: Int = 0, output: MutableList<String>) {
if (depth > 5) return
reader.eachBuffer { _, buffer ->
if (buffer.isEmpty()) return@eachBuffer
if (isAscii(buffer)) {
output.add(buffer.toString(Charsets.UTF_8))
}
runCatching { ProtoReader(buffer) }.getOrNull()?.let {
collectStrings(it, depth + 1, output)
}
}
}
private fun parseObjectPayload(payload: String): Map<String, String> {
val regex = Regex("\"([^\"]+)\"\\s*:\\s*(\"[^\"]*\"|true|false|-?\\d+(?:\\.\\d+)?)")
return regex.findAll(payload).associate { match ->
val key = match.groupValues[1]
val rawValue = match.groupValues[2]
key to rawValue.trim('"')
}
}
@Synchronized
private fun updateFromPayload(payload: String) {
if (!payload.startsWith("{") || !payload.endsWith("}")) return
val parsed = parseObjectPayload(payload)
if (parsed.isEmpty()) return
parsed["callUuid"]?.let { newCallUuid ->
if (metadata.callUuid != null && metadata.callUuid != newCallUuid && metadata.hasData()) {
notifyCallEnded("new_call_boundary")
}
metadata.callUuid = newCallUuid
}
parsed["attemptId"]?.let { metadata.attemptId = it }
parsed["media"]?.let { metadata.media = it }
parsed["isGroup"]?.let { metadata.isGroup = it }
parsed["scopeId"]?.let { metadata.scopeId = it }
parsed["ipv4Address"]?.let { metadata.ipv4Address = it }
parsed["messageType"]?.let { metadata.messageTypes.add(it) }
parsed["callAction"]?.let {
metadata.messageTypes.add("callAction:$it")
if (it.equals("START", ignoreCase = true) && metadata.startTimestamp == null) {
metadata.startTimestamp = System.currentTimeMillis()
wasInCall = true
}
if (it.equals("END", ignoreCase = true) || it.equals("HANGUP", ignoreCase = true)) {
handleCallEnded("call_action:$it")
}
}
metadata.rawPayloads.add(payload)
}
@Synchronized
private fun handleVolatileEvent(reader: ProtoReader) {
val payloads = mutableListOf<String>()
collectStrings(reader, output = payloads)
payloads.forEach { payload ->
updateFromPayload(payload)
val normalized = payload.lowercase(Locale.ROOT)
if ("caller_hangup" in normalized || "\"messagetype\":\"call_end\"" in normalized) {
handleCallEnded("volatile_end")
}
}
}
@Synchronized
private fun handleCallStarted(reason: String) {
if (!wasInCall) {
wasInCall = true
if (metadata.startTimestamp == null) {
metadata.startTimestamp = System.currentTimeMillis()
}
}
metadata.messageTypes.add("state:$reason")
}
@Synchronized
private fun handleCallEnded(reason: String) {
val shouldNotify = wasInCall || metadata.hasData()
wasInCall = false
if (!shouldNotify) return
notifyCallEnded(reason)
}
@Synchronized
private fun notifyCallEnded(reason: String) {
if (!metadata.hasData()) return
metadata.endedTimestamp = System.currentTimeMillis()
val lines = buildList {
metadata.ipv4Address?.let { add("IP: $it") }
metadata.scopeId?.let { add("Scope ID: $it") }
metadata.callUuid?.let { add("Call UUID: $it") }
metadata.attemptId?.let { add("Attempt ID: $it") }
metadata.media?.let { add("Media: $it") }
metadata.isGroup?.let { add("Group Call: $it") }
if (metadata.startTimestamp != null) {
add("Started: ${DateFormat.getDateTimeInstance().format(Date(metadata.startTimestamp!!))}")
}
if (metadata.endedTimestamp != null) {
add("Ended: ${DateFormat.getDateTimeInstance().format(Date(metadata.endedTimestamp!!))}")
}
if (metadata.messageTypes.isNotEmpty()) {
add("Events: ${metadata.messageTypes.joinToString(", ")}")
}
add("Reason: $reason")
}
val notification = Notification.Builder(context.androidContext, notificationChannelId)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(translation["notification_title"])
.setContentText(lines.firstOrNull() ?: translation["notification_empty"])
.setStyle(Notification.BigTextStyle().bigText(lines.joinToString("\n")))
.setAutoCancel(true)
.setShowWhen(true)
.setWhen(System.currentTimeMillis())
.build()
notificationManager.notify((metadata.callUuid ?: metadata.attemptId ?: reason).hashCode(), notification)
metadata.reset()
wasInCall = false
}
override fun init() {
if (!context.config.messaging.callMetadataNotifier.get()) return
runCatching {
findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").hook("onReceive", HookStage.BEFORE) { param ->
val buffer = param.argNullable<ByteBuffer>(0) ?: return@hook
val duplicate = buffer.duplicate().apply { position(0) }
val bytes = ByteArray(duplicate.limit())
duplicate.get(bytes)
val reader = ProtoReader(bytes)
if (reader.getString(1, 1) != "volatile") return@hook
val eventData = reader.followPath(1, 2) ?: return@hook
handleVolatileEvent(eventData)
}
}
runCatching {
findClass("com.google.firebase.messaging.FirebaseMessagingService")
.methods
.first {
it.declaringClass.name == "com.google.firebase.messaging.FirebaseMessagingService" &&
it.returnType == Void::class.javaPrimitiveType &&
it.parameterCount == 1 &&
it.parameterTypes[0] == Intent::class.java
}
.hook(HookStage.BEFORE) { param ->
val intent = param.argNullable<Intent>(0) ?: return@hook
when (intent.getStringExtra("type")?.lowercase(Locale.ROOT)) {
"abandon_audio", "abandon_video" -> handleCallEnded("firebase:${intent.getStringExtra("type")}")
}
}
}
listOf(
"com.snapchat.talkcorev3.TalkCore\$CppProxy",
"com.snapchat.talkcorev4.TalkCore\$CppProxy",
"com.snapchat.talkcore.TalkCore\$CppProxy"
).forEach { className ->
runCatching {
findClass(className).apply {
hook("updateTSCallingSession", HookStage.BEFORE) { param ->
val params = param.argNullable<Any>(0) ?: return@hook
val inCall = params.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hook
if (inCall) handleCallStarted("talkcore:update_true")
else handleCallEnded("talkcore:update_false")
}
hook("disposeTSCallingSession", HookStage.BEFORE) {
handleCallEnded("talkcore:dispose")
}
}
}
}
listOf(
"com.snapchat.talkcorev3.TSCallingStateUpdateParams",
"com.snapchat.talkcorev4.TSCallingStateUpdateParams",
"com.snapchat.talkcore.TSCallingStateUpdateParams"
).forEach { className ->
runCatching {
findClass(className).hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val inCall = instance.getObjectFieldOrNull("mInCall") as? Boolean ?: return@hookConstructor
if (inCall) handleCallStarted("params_ctor:true")
else handleCallEnded("params_ctor:false")
}
}
}
}
}

View File

@@ -0,0 +1,126 @@
package me.eternal.purrfectsnap.core.features.impl.messaging
import android.media.AudioManager
import android.media.ToneGenerator
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.core.event.events.impl.ConversationUpdateEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.features.Feature
class ConversationSoundEffects : Feature("Conversation Sound Effects") {
private val seenIncomingMessageIds = LinkedHashSet<Long>()
private val maxTrackedMessages = 512
private data class ToneStep(
val tone: Int,
val durationMs: Int,
val pauseAfterMs: Long = 0L
)
private data class ToneSpec(
val sendPattern: List<ToneStep>,
val receivePattern: List<ToneStep>
)
private fun currentConversationId() = context.feature(Messaging::class).openedConversationUUID?.toString()
private fun styleSpec(): ToneSpec {
return when (context.config.messaging.conversationSoundEffectsStyle.get()) {
"telegram" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_BEEP, 35),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 45, 25)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 70),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 35, 20)
)
)
"whatsapp" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 55)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 85),
ToneStep(ToneGenerator.TONE_PROP_ACK, 35, 15)
)
)
"subtle" -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_PROMPT, 22)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 28)
)
)
else -> ToneSpec(
sendPattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_PROMPT, 40),
ToneStep(ToneGenerator.TONE_PROP_BEEP, 28, 18)
),
receivePattern = listOf(
ToneStep(ToneGenerator.TONE_PROP_ACK, 55),
ToneStep(ToneGenerator.TONE_PROP_BEEP2, 40, 22)
)
)
}
}
private fun playPattern(pattern: List<ToneStep>) {
if (context.isMainActivityPaused) return
context.executeAsync {
var toneGenerator: ToneGenerator? = null
runCatching {
toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 55)
pattern.forEach { step ->
toneGenerator?.startTone(step.tone, step.durationMs)
if (step.pauseAfterMs > 0) delay(step.pauseAfterMs)
}
}.also {
runCatching { toneGenerator?.release() }
}
}
}
private fun markSeen(messageId: Long): Boolean {
synchronized(seenIncomingMessageIds) {
val added = seenIncomingMessageIds.add(messageId)
while (seenIncomingMessageIds.size > maxTrackedMessages) {
seenIncomingMessageIds.remove(seenIncomingMessageIds.first())
}
return added
}
}
override fun init() {
if (!context.config.messaging.conversationSoundEffects.get()) return
context.event.subscribe(SendMessageWithContentEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.destinations.conversations?.none { it.toString() == activeConversationId } != false) return@subscribe
event.addCallbackResult("onSuccess") {
val spec = styleSpec()
playPattern(spec.sendPattern)
}
}
context.event.subscribe(ConversationUpdateEvent::class) { event ->
val activeConversationId = currentConversationId() ?: return@subscribe
if (event.conversationId != activeConversationId) return@subscribe
val myUserId = context.database.myUserId ?: return@subscribe
val spec = styleSpec()
event.messages
.asSequence()
.filter { it.senderId?.toString() != myUserId }
.mapNotNull { it.messageDescriptor?.messageId }
.filter { markSeen(it) }
.firstOrNull()
?.let {
playPattern(spec.receivePattern)
}
}
}
}

View File

@@ -22,6 +22,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.ui.text.input.KeyboardType
import kotlinx.coroutines.*
import me.eternal.purrfectsnap.bridge.task.TaskListener
import me.eternal.purrfectsnap.common.data.ContentType
@@ -32,19 +35,26 @@ import me.eternal.purrfectsnap.common.util.protobuf.ProtoWriter
import me.eternal.purrfectsnap.core.event.events.impl.MediaUploadEvent
import me.eternal.purrfectsnap.core.event.events.impl.NativeUnaryCallEvent
import me.eternal.purrfectsnap.core.event.events.impl.SendMessageWithContentEvent
import me.eternal.purrfectsnap.core.event.events.impl.UnaryCallEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.experiments.MediaFilePicker
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.wrapper.impl.MessageContent
import me.eternal.purrfectsnap.core.wrapper.impl.MessageDestinations
import me.eternal.purrfectsnap.core.util.ktx.getObjectFieldOrNull
import me.eternal.purrfectsnap.core.util.ktx.setObjectField
import me.eternal.purrfectsnap.core.util.CallbackBuilder
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.util.hook.hookConstructor
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Collections
import java.util.IdentityHashMap
import java.util.Locale
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@@ -54,9 +64,45 @@ import kotlin.time.toDuration
class SendOverride : Feature("Send Override") {
companion object {
private const val NOTIFICATION_CHANNEL_ID = "scheduled_send"
private val internalMultipartSend = ThreadLocal.withInitial { false }
private var queuedOriginalItemRepeatCount = 0
private var queuedOriginalItemRepeatOverrideType: String? = null
private fun queueOriginalItemRepeats(repeatCount: Int, overrideType: String) {
queuedOriginalItemRepeatCount = repeatCount
queuedOriginalItemRepeatOverrideType = overrideType
MediaFilePicker.setQueuedOverrideType(overrideType)
}
private fun clearQueuedOriginalItemRepeats() {
queuedOriginalItemRepeatCount = 0
queuedOriginalItemRepeatOverrideType = null
}
private fun handleQueuedOriginalItemRepeatSuccess(): Boolean {
if (queuedOriginalItemRepeatCount <= 0) {
clearQueuedOriginalItemRepeats()
return false
}
val overrideType = queuedOriginalItemRepeatOverrideType ?: run {
clearQueuedOriginalItemRepeats()
return false
}
queuedOriginalItemRepeatCount--
MediaFilePicker.setQueuedOverrideType(overrideType)
val result = MediaFilePicker.sendReusableOriginalItem()
if (!result) {
queuedOriginalItemRepeatCount++
clearQueuedOriginalItemRepeats()
}
return result
}
}
private var selectedType by mutableStateOf("SNAP")
private var disableSplitForCurrentSend by mutableStateOf(false)
private var customDuration by mutableFloatStateOf(10f)
private var scheduledTime by mutableStateOf<Long?>(null)
private var showClockPicker by mutableStateOf(false)
@@ -66,7 +112,6 @@ class SendOverride : Feature("Send Override") {
private val backgroundHookLock = Any()
private var backgroundHookRefs = 0
private var backgroundHooks: List<Hooker.HookHandle>? = null
private fun acquireScheduledSendBackground(): () -> Unit {
if (!context.config.messaging.scheduledSendAllowRunningInBackground.get()) return {}
var enableFailed = false
@@ -362,7 +407,12 @@ class SendOverride : Feature("Send Override") {
}
}
context.event.subscribe(UnaryCallEvent::class, priority = 100) { event ->
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
}
context.event.subscribe(SendMessageWithContentEvent::class, priority = -100) { event ->
if (internalMultipartSend.get() == true) return@subscribe
postSavePolicy = null
if (event.destinations.stories?.isNotEmpty() == true && event.destinations.conversations?.isEmpty() == true) return@subscribe
val localMessageContent = event.messageContent
@@ -394,6 +444,7 @@ class SendOverride : Feature("Send Override") {
val recipientName = recipientNames.joinToString(", ")
event.canceled = true
event.adapter.setResult(null)
fun invokeOriginalAndRestoreResult(ev: SendMessageWithContentEvent) {
val result = ev.adapter.invokeOriginal()
@@ -401,9 +452,54 @@ class SendOverride : Feature("Send Override") {
ev.canceled = false
}
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
val sendMessageCallbackClass by lazy {
lateinit var result: Class<*>
context.mappings.useMapper(CallbackMapper::class) {
result = callbacks.getClass("SendMessageCallback") ?: error("Failed to resolve SendMessageCallback")
}
result
}
fun cloneDestinations(source: MessageDestinations): Any {
return context.gson.fromJson(
context.gson.toJson(source.instanceNonNull()),
context.classCache.messageDestinations
)
}
val sendMessageWithContentMethod by lazy {
sequence {
var current: Class<*>? = context.classCache.conversationManager
while (current != null && current != Any::class.java && current != Object::class.java) {
yield(current)
current = current.superclass
}
}.flatMap { it.declaredMethods.asSequence() }
.first { it.name == "sendMessageWithContent" }
}
val originalMessageJson = context.gson.toJson(localMessageContent.instanceNonNull())
val originalCallback = event.adapter.args().getOrNull(2)
val conversationManagerInstance by lazy {
context.feature(Messaging::class).conversationManager?.instanceNonNull()
}
fun invokeCallbackError(callback: Any?, error: Any?) {
runCatching {
callback?.javaClass?.methods?.firstOrNull { method ->
method.name == "onError" && method.parameterCount == 1
}?.invoke(callback, error)
}
}
fun applyOverride(
targetMessageContent: MessageContent,
targetReader: ProtoReader,
overrideType: String,
snapDurationMs: Int?
): Boolean {
val bypassLimit = context.config.experimental.nativeHooks.valdiHooks.bypassCameraRollLimit.get()
if (overrideType != "ORIGINAL" && !bypassLimit && (messageProtoReader.followPath(3)?.getCount(3) ?: 0) > 1) {
if (overrideType != "ORIGINAL" && !bypassLimit && (targetReader.followPath(3)?.getCount(3) ?: 0) > 1) {
context.inAppOverlay.showStatusToast(
icon = Icons.Default.WarningAmber,
context.translation["gallery_media_send_override.multiple_media_toast"]
@@ -416,10 +512,10 @@ class SendOverride : Feature("Send Override") {
val savePolicyValue = if (overrideType == "SAVEABLE_SNAP") 2 else 1
postSavePolicy = savePolicyValue
val extras = messageProtoReader.followPath(3, 3, 13)?.getBuffer()
val extras = targetReader.followPath(3, 3, 13)?.getBuffer()
if (localMessageContent.contentType != ContentType.SNAP) {
localMessageContent.content = ProtoWriter().apply {
if (targetMessageContent.contentType != ContentType.SNAP) {
targetMessageContent.content = ProtoWriter().apply {
from(11) {
from(5) {
from(1) {
@@ -440,11 +536,11 @@ class SendOverride : Feature("Send Override") {
}.toByteArray()
}
localMessageContent.contentType = ContentType.SNAP
localMessageContent.content = ProtoEditor(localMessageContent.content!!).apply {
targetMessageContent.contentType = ContentType.SNAP
targetMessageContent.content = ProtoEditor(targetMessageContent.content!!).apply {
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)
addVarInt(5, targetReader.getVarInt(3, 3, 5, 2, 5) ?: targetReader.getVarInt(11, 5, 2, 5) ?: 1)
if (snapDurationMs != null && overrideType != "SAVEABLE_SNAP") {
addVarInt(8, snapDurationMs / 1000)
if (snapDurationMs / 1000 <= 0) {
@@ -474,11 +570,11 @@ class SendOverride : Feature("Send Override") {
if (shouldPreventSave) {
postSavePolicy = 1 // PROHIBITED
}
localMessageContent.contentType = ContentType.NOTE
targetMessageContent.contentType = ContentType.NOTE
val stripMeta = context.config.messaging.stripMediaMetadata.get()
val omitTranscript = stripMeta.contains("remove_audio_note_transcript_capability")
val rawDurationMs = messageProtoReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
?: messageProtoReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
val rawDurationMs = targetReader.getVarInt(3, 3, 5, 1, 1, 15)?.toLong()
?: targetReader.getVarInt(3, 3, 5, 2, 8)?.toLong()?.times(1000)
?: (context.feature(MediaFilePicker::class).lastMediaDuration ?: 0).toLong()
val durationForProto = minOf(rawDurationMs, MessageSender.VOICE_NOTE_MAX_DURATION_MS)
val audioNoteProto = MessageSender.audioNoteProto(
@@ -487,7 +583,7 @@ class SendOverride : Feature("Send Override") {
)
// Set save policy in the proto if prevent audio is enabled
localMessageContent.content = if (shouldPreventSave) {
targetMessageContent.content = if (shouldPreventSave) {
// Check which path structure exists in the audio note proto
val protoReader = ProtoReader(audioNoteProto)
val hasNestedPath = protoReader.followPath(6, 1, 1) != null
@@ -519,7 +615,7 @@ class SendOverride : Feature("Send Override") {
Class.forName(
"com.snapchat.client.messaging.SavePolicy",
false,
localMessageContent.instanceNonNull().javaClass.classLoader
targetMessageContent.instanceNonNull().javaClass.classLoader
)
}.getOrNull()
@@ -537,7 +633,7 @@ class SendOverride : Feature("Send Override") {
}.getOrNull()
if (policyEnum != null) {
localMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
targetMessageContent.instanceNonNull().setObjectField("mSavePolicy", policyEnum)
}
}
}
@@ -549,17 +645,228 @@ class SendOverride : Feature("Send Override") {
return true
}
val resolvedOverrideType = configOverrideType?.takeIf { it != "always_ask" }
fun createMessageContentFromOriginal(): MessageContent {
return MessageContent(
context.gson.fromJson(originalMessageJson, context.classCache.localMessageContent)
).also { messageContent ->
val visited = Collections.newSetFromMap(IdentityHashMap<Any, Boolean>())
fun shouldScrubField(fieldName: String): Boolean {
if (fieldName == "mId") return false
return fieldName in setOf("mMessageId", "mQuotedMessageId") ||
fieldName.contains("AttemptId", ignoreCase = true) ||
fieldName.contains("ClientMessageId", ignoreCase = true) ||
fieldName.contains("ClientId", ignoreCase = true) ||
fieldName.contains("MessageUuid", ignoreCase = true) ||
fieldName.contains("UUID", ignoreCase = true)
}
fun scrubValue(value: Any?) {
if (value == null) return
if (!visited.add(value)) return
when (value) {
is String, is Number, is Boolean, is ByteArray, is Enum<*> -> return
is Iterable<*> -> {
value.forEach { scrubValue(it) }
return
}
is Map<*, *> -> {
value.values.forEach { scrubValue(it) }
return
}
}
sequence<Class<*>> {
var current: Class<*>? = value.javaClass
while (current != null && current != Any::class.java && current != Object::class.java) {
yield(current)
current = current.superclass
}
}.flatMap { it.declaredFields.asSequence() }
.forEach { field ->
runCatching {
field.isAccessible = true
if (shouldScrubField(field.name)) {
when (field.type) {
java.lang.Long.TYPE -> field.setLong(value, 0L)
java.lang.Integer.TYPE -> field.setInt(value, 0)
java.lang.Boolean.TYPE -> field.setBoolean(value, false)
else -> field.set(value, null)
}
} else {
scrubValue(field.get(value))
}
}
}
}
scrubValue(messageContent.instanceNonNull())
}
}
fun invokeSendManually(messageContent: MessageContent, callback: Any?) {
val conversationManager = conversationManagerInstance ?: error("ConversationManager is null")
internalMultipartSend.set(true)
try {
sendMessageWithContentMethod.invoke(
conversationManager,
cloneDestinations(event.destinations),
messageContent.instanceNonNull(),
callback
)
} finally {
internalMultipartSend.set(false)
}
}
fun sendMediaManual(
sourceMessageContent: MessageContent,
overrideType: String,
snapDurationMs: Int?,
completionCallback: Any?
): Boolean {
val sourceReader = ProtoReader(sourceMessageContent.content ?: return false)
val mediaCount = sourceReader.followPath(3)?.getCount(3) ?: 0
if (overrideType != "ORIGINAL" && mediaCount > 1) {
val mediaBuffers = mutableListOf<ByteArray>()
sourceReader.followPath(3)?.eachBuffer { id, buffer ->
if (id == 3) mediaBuffers.add(buffer)
}
if (mediaBuffers.isEmpty()) return false
fun buildPartMessageContent(partIndex: Int): MessageContent {
val partContent = createMessageContentFromOriginal()
val metadata = partContent.instanceNonNull().getObjectFieldOrNull("mExternalContentMetadata")
val refs = ArrayList(partContent.localMediaReferences ?: arrayListOf())
val contentRefs = (metadata?.getObjectFieldOrNull("mContentReferences") as? ArrayList<*>)?.toCollection(ArrayList())
val encryptionRefs = (metadata?.getObjectFieldOrNull("mRemoteMediaEncryption") as? ArrayList<*>)?.toCollection(ArrayList())
partContent.content = ProtoEditor(partContent.content!!).apply {
edit(3) {
remove(3)
addBuffer(3, mediaBuffers[partIndex])
}
}.toByteArray()
if (partIndex < refs.size) {
partContent.localMediaReferences = arrayListOf(refs[partIndex])
}
metadata?.let {
if (contentRefs != null && partIndex < contentRefs.size) {
it.setObjectField("mContentReferences", arrayListOf(contentRefs[partIndex]))
}
if (encryptionRefs != null && partIndex < encryptionRefs.size) {
it.setObjectField("mRemoteMediaEncryption", arrayListOf(encryptionRefs[partIndex]))
}
}
return partContent
}
fun sendPart(partIndex: Int) {
postSavePolicy = null
val partContent = buildPartMessageContent(partIndex)
val partReader = ProtoReader(partContent.content ?: return)
if (!applyOverride(partContent, partReader, overrideType, snapDurationMs)) return
val callback = if (partIndex == mediaCount - 1) {
completionCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendPart(partIndex + 1)
}
.override("onError", shouldUnhook = false) {
invokeCallbackError(completionCallback, it.argNullable<Any>(0))
}
.build()
}
invokeSendManually(partContent, callback)
}
sendPart(0)
return true
}
postSavePolicy = null
val targetReader = ProtoReader(sourceMessageContent.content ?: return false)
if (!applyOverride(sourceMessageContent, targetReader, overrideType, snapDurationMs)) return false
invokeSendManually(sourceMessageContent, completionCallback)
return true
}
fun sendRepeatedMediaManual(
repeatCount: Int,
overrideType: String,
snapDurationMs: Int?
): Boolean {
if (repeatCount <= 0) return false
fun sendIteration(index: Int) {
val callback = if (index == repeatCount - 1) {
originalCallback
} else {
CallbackBuilder(sendMessageCallbackClass)
.override("onSuccess") {
sendIteration(index + 1)
}
.override("onError", shouldUnhook = false) {
invokeCallbackError(originalCallback, it.argNullable<Any>(0))
}
.build()
}
val preparedContent = createMessageContentFromOriginal()
if (!sendMediaManual(preparedContent, overrideType, snapDurationMs, callback)) {
invokeCallbackError(originalCallback, "Failed to send")
}
}
sendIteration(0)
return true
}
fun sendMedia(overrideType: String, snapDurationMs: Int?): Boolean {
postSavePolicy = null
return applyOverride(localMessageContent, messageProtoReader, overrideType, snapDurationMs)
}
val resolvedOverrideType = MediaFilePicker.getQueuedOverrideType()
?: configOverrideType?.takeIf { it != "always_ask" }
fun attachQueuedRepeatCallbacks(sendEvent: SendMessageWithContentEvent) {
sendEvent.addCallbackResult("onSuccess") {
context.runOnUiThread {
val handledSplit = MediaFilePicker.handleCurrentQueuedItemSuccess()
val handledRepeat = if (!handledSplit) {
handleQueuedOriginalItemRepeatSuccess()
} else {
false
}
if (!handledSplit && !handledRepeat) {
MediaFilePicker.clearQueuedSplitItems()
clearQueuedOriginalItemRepeats()
}
}
}
sendEvent.addCallbackResult("onError") {
MediaFilePicker.clearQueuedSplitItems()
clearQueuedOriginalItemRepeats()
}
}
if (resolvedOverrideType != null) {
if (MediaFilePicker.hasPendingSplitCleanup() || MediaFilePicker.getQueuedOverrideType() != null || queuedOriginalItemRepeatCount > 0) {
attachQueuedRepeatCallbacks(event)
}
if (sendMedia(resolvedOverrideType, 10000)) {
invokeOriginalAndRestoreResult(event)
if (event.canceled) invokeOriginalAndRestoreResult(event)
}
return@subscribe
}
context.runOnUiThread {
val recipientNameForTask = recipientName
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
val mediaCount = messageProtoReader.followPath(3)?.getCount(3) ?: 0
createComposeAlertDialog(context.mainActivity!!) { alertDialog ->
PurrfectOverlayTheme {
@@ -649,6 +956,8 @@ class SendOverride : Feature("Send Override") {
context.translation.getCategory("features.options.gallery_media_send_override")
}
var scheduleEnabled by remember { mutableStateOf(false) }
var continuousSendEnabled by remember { mutableStateOf(false) }
var continuousSendCount by remember { mutableStateOf("2") }
Text(
fontSize = 20.sp,
@@ -713,6 +1022,21 @@ class SendOverride : Feature("Send Override") {
fun toggleSaveable() {
selectedType = if (selectedType == "SAVEABLE_SNAP") "SNAP" else "SAVEABLE_SNAP"
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
disableSplitForCurrentSend = !disableSplitForCurrentSend
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = disableSplitForCurrentSend,
onCheckedChange = {
disableSplitForCurrentSend = it
}
)
Text(text = mainTranslation["single_send_hint"], lineHeight = 15.sp)
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
toggleSaveable()
@@ -749,6 +1073,42 @@ class SendOverride : Feature("Send Override") {
}
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
continuousSendEnabled = !continuousSendEnabled
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = continuousSendEnabled,
onCheckedChange = {
continuousSendEnabled = it
}
)
Text(text = mainTranslation["continuous_send_toggle"], lineHeight = 15.sp)
}
if (continuousSendEnabled) {
OutlinedTextField(
value = continuousSendCount,
onValueChange = { value ->
continuousSendCount = value.filter(Char::isDigit).take(3)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(mainTranslation["continuous_send_count_label"]) },
placeholder = { Text(mainTranslation["continuous_send_count_placeholder"]) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
keyboardActions = KeyboardActions.Default
)
Text(
text = mainTranslation["continuous_send_hint"],
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.72f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
@@ -913,8 +1273,46 @@ class SendOverride : Feature("Send Override") {
Text(context.translation["button.cancel"])
}
Button(onClick = {
alertDialog.dismiss()
val finalSelectedType = selectedType
val repeatCount = if (continuousSendEnabled) {
continuousSendCount.toIntOrNull()?.takeIf { it > 0 }
} else {
1
}
if (repeatCount == null) {
context.inAppOverlay.showStatusToast(
icon = Icons.Default.WarningAmber,
text = mainTranslation["continuous_send_invalid_count"]
)
return@Button
}
if (repeatCount > 1 && disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
context.inAppOverlay.showStatusToast(
icon = Icons.Default.WarningAmber,
text = mainTranslation["continuous_send_single_send_conflict"]
)
return@Button
}
alertDialog.dismiss()
if (disableSplitForCurrentSend && MediaFilePicker.hasOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
if (!MediaFilePicker.sendOriginalUnsplitItem()) {
MediaFilePicker.setQueuedOverrideType(null)
}
return@Button
} else if (MediaFilePicker.hasPendingSplitCleanup()) {
MediaFilePicker.setQueuedOverrideType(finalSelectedType)
event.addCallbackResult("onSuccess") {
context.runOnUiThread {
if (!MediaFilePicker.handleCurrentQueuedItemSuccess()) {
MediaFilePicker.clearQueuedSplitItems()
}
}
}
event.addCallbackResult("onError") {
MediaFilePicker.clearQueuedSplitItems()
}
}
val delayMs = scheduledTime?.let { it - System.currentTimeMillis() }
if (delayMs != null && delayMs > 0) {
val taskHash = java.util.UUID.randomUUID().toString()
@@ -960,8 +1358,11 @@ class SendOverride : Feature("Send Override") {
context.bridgeClient.getTaskInterface().updateTaskProgress(taskHash, "Sending...", 100)
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
if (sendRepeatedMediaManual(
repeatCount,
finalSelectedType,
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
)) {
val successText = context.translation.format("schedule_sent_to", "name" to recipientNameForTask) ?: "Sent to $recipientNameForTask"
context.inAppOverlay.showStatusToast(
icon = Icons.Filled.CheckCircle,
@@ -1008,8 +1409,24 @@ class SendOverride : Feature("Send Override") {
}
}
} else {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
if (repeatCount == 1) {
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
}
} else if (MediaFilePicker.hasReusableOriginalItem()) {
queueOriginalItemRepeats(repeatCount - 1, finalSelectedType)
attachQueuedRepeatCallbacks(event)
if (sendMedia(finalSelectedType, if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null)) {
invokeOriginalAndRestoreResult(event)
} else {
clearQueuedOriginalItemRepeats()
}
} else {
sendRepeatedMediaManual(
repeatCount,
finalSelectedType,
if (finalSelectedType != "SAVEABLE_SNAP") convertDuration(customDuration) else null
)
}
}
}) {

View File

@@ -5,6 +5,8 @@ import android.annotation.SuppressLint
import android.content.ContextWrapper
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.MediaRecorder
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraCharacteristics.Key
import android.hardware.camera2.CameraManager
@@ -27,6 +29,34 @@ class CameraTweaks : Feature("Camera Tweaks") {
override fun init() {
val config = context.config.camera
// Toggle A: Audio & Video Optimizations (Bitrates)
if (config.audioVideoOptimizations.get()) {
MediaRecorder::class.java.hook("setVideoEncodingBitRate", HookStage.BEFORE) { param ->
val currentRate = param.arg<Int>(0)
if (currentRate < 30_000_000) param.setArg(0, 30_000_000)
}
MediaRecorder::class.java.hook("setAudioEncodingBitRate", HookStage.BEFORE) { param ->
param.setArg(0, 320_000)
}
MediaRecorder::class.java.hook("setAudioSamplingRate", HookStage.BEFORE) { param ->
param.setArg(0, 48_000)
}
}
// Toggle B: Camera Optimizations (Hardware ISP - UNSTABLE)
if (config.cameraOptimizations.get()) {
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
val key = param.arg<CaptureRequest.Key<*>>(0)
when (key) {
CaptureRequest.EDGE_MODE -> param.setArg(1, CaptureRequest.EDGE_MODE_HIGH_QUALITY)
CaptureRequest.NOISE_REDUCTION_MODE -> param.setArg(1, CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY)
CaptureRequest.HOT_PIXEL_MODE -> param.setArg(1, CaptureRequest.HOT_PIXEL_MODE_HIGH_QUALITY)
CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE -> param.setArg(1, CaptureRequest.COLOR_CORRECTION_ABERRATION_MODE_HIGH_QUALITY)
CaptureRequest.CONTROL_AF_MODE -> param.setArg(1, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
}
}
}
val frontCameraId by lazy {
runCatching { context.androidContext.getSystemService(CameraManager::class.java).run {
cameraIdList.firstOrNull { getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT }
@@ -91,6 +121,11 @@ class CameraTweaks : Feature("Camera Tweaks") {
param.setArg(1, captureResolutionConfig[1])
}
CaptureRequest.Builder::class.java.hook("set", HookStage.BEFORE) { param ->
val key = param.arg<CaptureRequest.Key<*>>(0)
if (key == CaptureRequest.CONTROL_ZOOM_RATIO) return@hook
}
CameraCharacteristics::class.java.hook("get", HookStage.AFTER) { param ->
val key = param.argNullable<Key<*>>(0) ?: return@hook
@@ -105,12 +140,16 @@ class CameraTweaks : Feature("Camera Tweaks") {
}
}
if (key == CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES) {
val isFrontCamera = param.invokeOriginal(
arrayOf(CameraCharacteristics.LENS_FACING)
) == CameraCharacteristics.LENS_FACING_FRONT
val customFrameRate = (if (isFrontCamera) config.frontCustomFrameRate.getNullable() else config.backCustomFrameRate.getNullable())?.toIntOrNull() ?: return@hook
param.setResult(arrayOf(Range(customFrameRate, customFrameRate)))
if (config.unlockZoomLimit.get()) {
val maxZoom = config.maxZoomOverride.get().coerceAtLeast(1f)
when {
key == CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM -> {
param.setResult(maxZoom)
}
key.name == "android.control.zoomRatioRange" -> {
param.setResult(Range(1f, maxZoom))
}
}
}
}

View File

@@ -1,10 +1,13 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.content.res.TypedArray
import android.graphics.drawable.ColorDrawable
import android.util.TypedValue
import android.view.View
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
import me.eternal.purrfectsnap.core.util.hook.hookConstructor
import me.eternal.purrfectsnap.core.util.ktx.getObjectField
class CustomTheming : Feature("Custom Theming") {
@@ -50,37 +53,80 @@ class CustomTheming : Feature("Custom Theming") {
).any { it in name }
}
private fun patchTypedArray(result: TypedArray, attrIds: IntArray?) {
val requestedAttrs = attrIds?.takeIf { it.isNotEmpty() } ?: return
val typedArrayData = runCatching { result.getObjectField("mData") as IntArray }.getOrNull() ?: return
val stride = (typedArrayData.size / requestedAttrs.size).takeIf { it >= 2 } ?: return
requestedAttrs.forEachIndexed { index, attrId ->
val offset = index * stride
if (offset + 1 >= typedArrayData.size) return@forEachIndexed
val type = typedArrayData[offset]
if (type !in colorTypes) return@forEachIndexed
val originalColor = runCatching { result.getColor(index, Int.MIN_VALUE) }.getOrNull()
?.takeIf { it != Int.MIN_VALUE }
?: return@forEachIndexed
val attrName = runCatching { context.androidContext.resources.getResourceEntryName(attrId) }.getOrNull()
val shouldPatch = attrId in patchedAttrIds || shouldPatch(attrId, attrName, originalColor)
if (!shouldPatch) return@forEachIndexed
typedArrayData[offset + 1] = amoledBlack
if (patchedAttrIds.add(attrId)) {
context.log.verbose(
"[AMOLED PATCH] Patched attrId 0x${attrId.toString(16)} (${attrName ?: "unknown"}) from 0x${originalColor.toUInt().toString(16)} to AMOLED black"
)
}
}
}
private fun patchProgrammaticColor(color: Int): Int {
return if (isNearBlackOpaque(color) && color != amoledBlack) amoledBlack else color
}
override fun init() {
if (!context.config.userInterface.forceAmoledTheme.get()) return
onNextActivityCreate {
context.androidContext.theme.javaClass
.getMethod("obtainStyledAttributes", IntArray::class.java)
.hook(HookStage.AFTER) { param ->
val array = param.arg<IntArray>(0)
val attrId = array[0]
val result = param.getResult() as TypedArray
val type = result.getType(0)
if (type !in colorTypes) return@hook
val originalColor = runCatching { result.getColor(0, Int.MIN_VALUE) }.getOrNull()
?.takeIf { it != Int.MIN_VALUE }
?: return@hook
val attrName = runCatching { context.androidContext.resources.getResourceEntryName(attrId) }.getOrNull()
val shouldPatch = attrId in patchedAttrIds || shouldPatch(attrId, attrName, originalColor)
if (!shouldPatch) return@hook
val typedArrayData = runCatching { result.getObjectField("mData") as IntArray }.getOrNull() ?: return@hook
if (typedArrayData.size < 2) return@hook
typedArrayData[1] = amoledBlack
if (patchedAttrIds.add(attrId)) {
context.log.verbose(
"[AMOLED PATCH] Patched attrId 0x${attrId.toString(16)} (${attrName ?: "unknown"}) from 0x${originalColor.toUInt().toString(16)} to AMOLED black"
)
}
.hook("obtainStyledAttributes", HookStage.AFTER) { param ->
val requestedAttrs = param.args().firstOrNull { it is IntArray } as? IntArray
val result = param.getResult() as? TypedArray ?: return@hook
patchTypedArray(result, requestedAttrs)
}
context.androidContext.javaClass
.hook("obtainStyledAttributes", HookStage.AFTER) { param ->
val requestedAttrs = param.args().firstOrNull { it is IntArray } as? IntArray
val result = param.getResult() as? TypedArray ?: return@hook
patchTypedArray(result, requestedAttrs)
}
View::class.java.hook("setBackgroundColor", HookStage.BEFORE) { param ->
val color = param.argNullable<Int>(0) ?: return@hook
val patched = patchProgrammaticColor(color)
if (patched != color) {
param.setArg(0, patched)
}
}
ColorDrawable::class.java.hookConstructor(HookStage.BEFORE) { param ->
val color = param.argNullable<Int>(0) ?: return@hookConstructor
val patched = patchProgrammaticColor(color)
if (patched != color) {
param.setArg(0, patched)
}
}
ColorDrawable::class.java.hook("setColor", HookStage.BEFORE) { param ->
val color = param.argNullable<Int>(0) ?: return@hook
val patched = patchProgrammaticColor(color)
if (patched != color) {
param.setArg(0, patched)
}
}
}
}
}

View File

@@ -0,0 +1,217 @@
package me.eternal.purrfectsnap.core.features.impl.ui
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.Close
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.gson.reflect.TypeToken
import me.eternal.purrfectsnap.common.data.ContentType
import me.eternal.purrfectsnap.common.database.impl.ConversationMessage
import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
import me.eternal.purrfectsnap.core.features.Feature
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.ui.CustomComposable
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayPalette
import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme
import me.eternal.purrfectsnap.core.wrapper.impl.getMessageText
import me.eternal.purrfectsnap.core.wrapper.impl.sanitizeForLayout
import kotlinx.coroutines.delay
class LocalPinnedMessages : Feature("Local Pinned Messages") {
data class PinnedMessageSnapshot(
val messageId: Long,
val senderName: String,
val preview: String,
val timestamp: Long
)
private val prefs by lazy {
context.androidContext.getSharedPreferences("purrfectsnap_local_pinned_messages", 0)
}
private var revision by mutableLongStateOf(0L)
private var trackedChatLayout by mutableStateOf<View?>(null)
private var trackedChatVisibility by mutableStateOf(false)
private fun readPins(): MutableMap<String, PinnedMessageSnapshot> {
val json = prefs.getString("pins", null) ?: return mutableMapOf()
return runCatching {
context.gson.fromJson<MutableMap<String, PinnedMessageSnapshot>>(
json,
object : TypeToken<MutableMap<String, PinnedMessageSnapshot>>() {}.type
) ?: mutableMapOf()
}.getOrElse { mutableMapOf() }
}
private fun writePins(pins: Map<String, PinnedMessageSnapshot>) {
prefs.edit().putString("pins", context.gson.toJson(pins)).apply()
revision++
}
private fun resolveMessagePreview(message: ConversationMessage): String {
val messageContainer = message.messageContent?.let { ProtoReader(it) }?.followPath(4, 4)
val contentType = ContentType.fromMessageContainer(messageContainer) ?: ContentType.fromId(message.contentType)
return messageContainer?.getBuffer()?.getMessageText(contentType)
?: "[${context.translation.getCategory("content_type")[contentType.name]}]"
}
private fun resolveSenderName(message: ConversationMessage): String {
return message.senderId?.let { senderId ->
context.database.getFriendInfo(senderId)?.let { it.displayName ?: it.mutableUsername }
} ?: context.translation.getCategory("logger_history")["unknown_sender"]
}
fun pinFocusedMessage() {
val messaging = context.feature(Messaging::class)
val conversationId = messaging.openedConversationUUID?.toString() ?: return
val messageId = messaging.lastFocusedMessageId.takeIf { it > 0 } ?: return
val message = context.database.getConversationMessageFromId(messageId) ?: return
val pins = readPins()
pins[conversationId] = PinnedMessageSnapshot(
messageId = messageId,
senderName = resolveSenderName(message),
preview = resolveMessagePreview(message).sanitizeForLayout(),
timestamp = message.creationTimestamp
)
writePins(pins)
context.shortToast(context.translation["local_pinned_messages.pinned_toast"])
}
fun unpinFocusedConversation() {
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return
val pins = readPins()
if (pins.remove(conversationId) != null) {
writePins(pins)
context.shortToast(context.translation["local_pinned_messages.unpinned_toast"])
}
}
fun hasPinnedMessageForOpenedConversation(): Boolean {
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return false
return readPins().containsKey(conversationId)
}
private fun isActuallyVisible(view: View): Boolean {
if (!view.isShown || view.visibility != View.VISIBLE || !view.isAttachedToWindow) return false
if (view.width <= 0 || view.height <= 0 || view.alpha <= 0f) return false
return view.getGlobalVisibleRect(Rect())
}
private fun trackChatLayoutCandidate(view: View?) {
view ?: return
trackedChatLayout = view
trackedChatVisibility = isActuallyVisible(view)
}
override fun init() {
context.event.subscribe(AddViewEvent::class) { event ->
when {
event.parent.javaClass.name.endsWith("ChatInputLayout") -> {
trackChatLayoutCandidate(event.parent)
}
event.viewClassName.endsWith("ChatInputLayout") -> {
trackChatLayoutCandidate(event.view)
}
}
}
onNextActivityCreate {
trackedChatLayout = null
trackedChatVisibility = false
}
lateinit var pinnedComposable: CustomComposable
pinnedComposable = {
revision
val trackedLayout = trackedChatLayout
var currentConversationId by remember { mutableStateOf<String?>(null) }
LaunchedEffect(revision, trackedLayout) {
while (true) {
currentConversationId = context.feature(Messaging::class).openedConversationUUID?.toString()
trackedChatVisibility = trackedLayout?.let { isActuallyVisible(it) } == true
delay(16)
}
}
val conversationId = currentConversationId
if (conversationId != null && trackedChatVisibility) {
val pinned = remember(revision, conversationId) { readPins()[conversationId] }
if (pinned != null) {
Box(
modifier = Modifier
.padding(top = 62.dp, start = 14.dp, end = 14.dp)
.align(Alignment.TopCenter)
) {
PurrfectOverlayTheme {
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectOverlayPalette.cardOverlayColor.copy(alpha = 0.95f), shape)
.border(1.dp, Color.White.copy(alpha = 0.10f), shape)
.clickable { }
.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Outlined.PushPin,
contentDescription = null,
tint = PurrfectOverlayPalette.glowPrimary
)
Text(
text = pinned.preview,
color = Color.White.copy(alpha = 0.95f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.clickable {
unpinFocusedConversation()
}
)
}
}
}
}
}
}
context.inAppOverlay.addCustomComposable(pinnedComposable)
}
}

View File

@@ -52,7 +52,11 @@ class MessageIndicators : Feature("Message Indicators") {
contentAlignment = Alignment.TopEnd
) {
val hasEncryption by rememberAsyncMutableState(defaultValue = false) {
reader.getByteArray(4, 3, 3) != null || reader.containsPath(3, 99, 3)
// Strictly detect Private Fidelius Wrap (1-on-1 private snaps)
reader.containsPath(4, 4, 1, 1) ||
reader.containsPath(4, 4, 1, 1, 1) ||
reader.getByteArray(4, 3, 3) != null ||
reader.containsPath(3, 99, 3)
}
val sentFromIosDevice by rememberAsyncMutableState(defaultValue = false) {
if (reader.containsPath(4, 4, 3)) !reader.containsPath(4, 4, 3, 3, 17) else reader.getVarInt(4, 4, 11, 17, 7) != null
@@ -137,4 +141,4 @@ class MessageIndicators : Feature("Message Indicators") {
}
}
}
}
}

View File

@@ -22,6 +22,7 @@ class OperaStoryOverlayState {
val totalCountState = mutableIntStateOf(0)
val snapSourceState = mutableStateOf<String?>(null)
val isInConversationState = mutableStateOf(false)
val storyIdentityState = mutableStateOf<String?>(null)
fun setupDisplayStateHook(
context: ModContext,
@@ -63,6 +64,14 @@ class OperaStoryOverlayState {
?: mediaParamMap["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull()
val totalCount = mediaParamMap["snap_story_length"]?.toString()?.toIntOrNull()
?: mediaParamMap["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull()
val storyIdentity = mediaParamMap["STORY_ID"]?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
?: mediaParamMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
?: mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()
?.substringAfter("storyUserId=", "")
?.substringBefore(",")
?.takeIf { it.isNotBlank() && it != "null" }
var mediaOrigin = ""
if (showSourceIndicator) {
@@ -81,6 +90,7 @@ class OperaStoryOverlayState {
totalCountState.intValue = totalCount ?: 0
snapSourceState.value = snapSource
isInConversationState.value = false
storyIdentityState.value = storyIdentity
onSnapFullyDisplayed?.let { callback ->
if (currentIndex != null) callback(currentIndex)

View File

@@ -26,6 +26,8 @@ class OperaStorySnapJump(
private var retryRunnable: Runnable? = null
private var nextTapRunnable: Runnable? = null
private var lastHandledIndex = -1
private var jumpOriginStoryIdentity: String? = null
private var jumpOriginTotalCount: Int = 0
fun simulateTap(forward: Boolean) {
val activity = context.mainActivity ?: return
@@ -83,6 +85,8 @@ class OperaStorySnapJump(
isJumping = false
jumpTargetIndex = -1
lastHandledIndex = -1
jumpOriginStoryIdentity = null
jumpOriginTotalCount = 0
mainHandler.postDelayed({
val overlay = storyFrameLayout()?.findViewWithTag<View>("jump_overlay") ?: return@postDelayed
overlay.animate()
@@ -99,6 +103,14 @@ class OperaStorySnapJump(
removeJumpOverlay()
return
}
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
removeJumpOverlay()
return
}
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
removeJumpOverlay()
return
}
val forward = jumpTargetIndex > fromIndex
simulateTap(forward)
@@ -114,6 +126,14 @@ class OperaStorySnapJump(
val retry = Runnable {
if (!isJumping || gen != jumpGeneration) return@Runnable
val currentIdx = overlayState.currentIndexState.intValue
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
removeJumpOverlay()
return@Runnable
}
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
removeJumpOverlay()
return@Runnable
}
if (currentIdx == fromIndex) {
if (retryCount >= maxRetries) {
removeJumpOverlay()
@@ -129,6 +149,14 @@ class OperaStorySnapJump(
fun onSnapFullyDisplayed(currentIndex: Int) {
if (!isJumping || jumpTargetIndex < 0) return
if (currentIndex == lastHandledIndex) return
if (jumpOriginStoryIdentity != null && overlayState.storyIdentityState.value != null && jumpOriginStoryIdentity != overlayState.storyIdentityState.value) {
removeJumpOverlay()
return
}
if (jumpOriginTotalCount > 0 && overlayState.totalCountState.intValue > 0 && jumpOriginTotalCount != overlayState.totalCountState.intValue) {
removeJumpOverlay()
return
}
cancelPendingRetry()
cancelPendingNextTap()
@@ -138,6 +166,18 @@ class OperaStorySnapJump(
return
}
if (lastHandledIndex >= 0) {
val expectedForward = jumpTargetIndex > lastHandledIndex
if (expectedForward && currentIndex < lastHandledIndex) {
removeJumpOverlay()
return
}
if (!expectedForward && currentIndex > lastHandledIndex) {
removeJumpOverlay()
return
}
}
lastHandledIndex = currentIndex
val gen = jumpGeneration
val tapRunnable = Runnable {
@@ -179,6 +219,8 @@ class OperaStorySnapJump(
jumpTargetIndex = targetIndex
lastHandledIndex = -1
isJumping = true
jumpOriginStoryIdentity = overlayState.storyIdentityState.value
jumpOriginTotalCount = overlayState.totalCountState.intValue
showJumpOverlay()

View File

@@ -28,6 +28,8 @@ class SnapPreview : Feature("SnapPreview") {
private val bitmapCache = EvictingMap<String, Bitmap>(50) // filePath => bitmap
private val fetchJobTab = randomTag()
private val previewHorizontalAdjustmentDp = 16
private val previewVerticalAdjustmentDp = 10
override fun init() {
if (!context.config.userInterface.snapPreview.get()) return
@@ -47,9 +49,11 @@ class SnapPreview : Feature("SnapPreview") {
}
onNextActivityCreate {
val (chatMediaCardHeight, chatMediaCardSnapMargin, chatMediaCardSnapMarginStartSdl) = context.userInterface.run {
Triple(dpToPx(60), dpToPx(10), dpToPx(15))
}
val chatMediaCardHeight = context.userInterface.dpToPx(60)
val chatMediaCardSnapMargin = context.userInterface.dpToPx(10)
val chatMediaCardSnapMarginStartSdl = context.userInterface.dpToPx(15)
val previewHorizontalAdjustment = context.userInterface.dpToPx(previewHorizontalAdjustmentDp)
val previewVerticalAdjustment = context.userInterface.dpToPx(previewVerticalAdjustmentDp)
fun decodeMedia(file: File) = runCatching {
bitmapCache.getOrPut(file.absolutePath) {
@@ -91,8 +95,8 @@ class SnapPreview : Feature("SnapPreview") {
val bitmap = bitmapCache[mediaFilePath] ?: return
canvas.drawBitmap(bitmap,
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat(),
(canvas.height - bitmap.height) / 2f,
canvas.width.toFloat() - bitmap.width - chatMediaCardSnapMarginStartSdl.toFloat() - chatMediaCardSnapMargin.toFloat() + previewHorizontalAdjustment,
(canvas.height - bitmap.height) / 2f + previewVerticalAdjustment,
null
)
}
@@ -101,4 +105,4 @@ class SnapPreview : Feature("SnapPreview") {
}
}
}
}
}

View File

@@ -117,4 +117,8 @@ class CoreMessagingBridge(
}
override fun getOneToOneConversationId(userId: String) = context.database.getDMConversationId(userId)
override fun getAutoOpenInterface(): me.eternal.purrfectsnap.bridge.AutoOpenInterface? {
return context.feature(me.eternal.purrfectsnap.core.features.impl.experiments.AutoOpenSnaps::class).getInterface()
}
}

View File

@@ -47,16 +47,31 @@ fun View.addForegroundDrawable(tag: String, drawable: Drawable) {
updateForegroundDrawable()
}
fun View.triggerCloseTouchEvent() {
arrayOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP).forEach {
this.dispatchTouchEvent(
MotionEvent.obtain(
SystemClock.uptimeMillis(),
SystemClock.uptimeMillis(),
it, 0f, 0f, 0
)
)
}
fun View.dispatchSyntheticTap(x: Float, y: Float, tapDurationMs: Long = 50L) {
val downTime = SystemClock.uptimeMillis()
val downEvent = MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, y, 0)
dispatchTouchEvent(downEvent)
downEvent.recycle()
val upEvent = MotionEvent.obtain(downTime, downTime + tapDurationMs, MotionEvent.ACTION_UP, x, y, 0)
dispatchTouchEvent(upEvent)
upEvent.recycle()
}
fun View.triggerCloseTouchEvent(x: Float = 0f, y: Float = 0f, tapDurationMs: Long = 50L) {
dispatchSyntheticTap(x, y, tapDurationMs)
}
fun View.triggerCloseTouchEventAtFraction(
xFraction: Float,
yFraction: Float = 0.5f,
tapDurationMs: Long = 50L
) {
val targetWidth = width.takeIf { it > 0 } ?: measuredWidth
val targetHeight = height.takeIf { it > 0 } ?: measuredHeight
val x = if (targetWidth > 0) targetWidth * xFraction.coerceIn(0f, 1f) else 0f
val y = if (targetHeight > 0) targetHeight * yFraction.coerceIn(0f, 1f) else 0f
triggerCloseTouchEvent(x, y, tapDurationMs)
}
fun Activity.triggerRootCloseTouchEvent() {

View File

@@ -14,6 +14,7 @@ import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
import me.eternal.purrfectsnap.core.features.impl.experiments.ConvertMessageLocally
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
import me.eternal.purrfectsnap.core.features.impl.ui.LocalPinnedMessages
import me.eternal.purrfectsnap.core.ui.ViewTagState
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
@@ -212,7 +213,24 @@ class ChatActionMenu : AbstractMenu() {
})
}
val pinnedMessages = context.feature(LocalPinnedMessages::class)
injectButton(Button(viewGroup.context).apply {
text = if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
this@ChatActionMenu.context.translation["chat_action_menu.unpin_local_message"]
} else {
this@ChatActionMenu.context.translation["chat_action_menu.pin_local_message"]
}
setOnClickListener {
closeActionMenu()
if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
pinnedMessages.unpinFocusedConversation()
} else {
pinnedMessages.pinFocusedMessage()
}
}
})
viewGroup.addView(buttonContainer)
}
}
}

View File

@@ -41,6 +41,7 @@ import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDeco
import me.eternal.purrfectsnap.core.features.impl.experiments.ConvertMessageLocally
import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging
import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger
import me.eternal.purrfectsnap.core.features.impl.ui.LocalPinnedMessages
import me.eternal.purrfectsnap.core.ui.ViewAppearanceHelper
import me.eternal.purrfectsnap.core.ui.debugEditText
import me.eternal.purrfectsnap.core.ui.iterateParent
@@ -343,6 +344,20 @@ class NewChatActionMenu : AbstractMenu() {
)
})
}
val pinnedMessages = context.feature(LocalPinnedMessages::class)
ListButton(
icon = Icons.Outlined.PushPin,
text = if (pinnedMessages.hasPinnedMessageForOpenedConversation()) context.translation["chat_action_menu.unpin_local_message"] else context.translation["chat_action_menu.pin_local_message"],
modifier = Modifier.clickable {
closeActionMenu()
if (pinnedMessages.hasPinnedMessageForOpenedConversation()) {
pinnedMessages.unpinFocusedConversation()
} else {
pinnedMessages.pinFocusedMessage()
}
}
)
}
}.apply {
layoutParams = LinearLayout.LayoutParams(

View File

@@ -45,6 +45,7 @@ import me.eternal.purrfectsnap.core.ui.iterateParent
import me.eternal.purrfectsnap.core.ui.menu.AbstractMenu
import me.eternal.purrfectsnap.core.ui.randomTag
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEvent
import me.eternal.purrfectsnap.core.ui.triggerCloseTouchEventAtFraction
import me.eternal.purrfectsnap.core.util.SNAPCHAT_13_80_VERSION
import me.eternal.purrfectsnap.core.util.hook.HookStage
import me.eternal.purrfectsnap.core.util.hook.hook
@@ -259,23 +260,113 @@ class OperaViewerIcons : AbstractMenu() {
visibleRect.width() > 0
}
private fun hasVisibleOpenLayout(view: View): Boolean {
private fun findVisibleOpenLayout(view: View): View? {
if (view.javaClass.hasNameSuffixInHierarchy("OpenLayout") && isActuallyVisible(view)) {
return true
return view
}
val viewGroup = view as? ViewGroup ?: return false
val viewGroup = view as? ViewGroup ?: return null
for (index in 0 until viewGroup.childCount) {
if (hasVisibleOpenLayout(viewGroup.getChildAt(index))) {
return true
}
findVisibleOpenLayout(viewGroup.getChildAt(index))?.let { return it }
}
return false
return null
}
private fun findVisibleModernViewerContainer(): View? {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content) ?: return null
return findVisibleOpenLayout(contentView)
}
private fun hasVisibleModernViewerContainer(): Boolean {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content) ?: return false
return hasVisibleOpenLayout(contentView)
return findVisibleModernViewerContainer() != null
}
private fun currentViewerMessageContext(mediaDownloader: MediaDownloader): OperaViewerMessageContext? {
return mediaDownloader.resolveViewerMessageContextFromParamMap()?.also {
viewerMessageContextState.value = it
} ?: viewerMessageContextState.value
}
private fun hasViewerAdvanced(
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext
): Boolean {
if (!hasVisibleModernViewerContainer()) {
return true
}
return currentViewerMessageContext(mediaDownloader)?.let { it != originalMessageContext } == true
}
private suspend fun waitForViewerAdvance(
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext,
timeoutMs: Long
): Boolean {
var elapsedMs = 0L
while (elapsedMs < timeoutMs) {
delay(40)
elapsedMs += 40
if (hasViewerAdvanced(mediaDownloader, originalMessageContext)) {
return true
}
}
return hasViewerAdvanced(mediaDownloader, originalMessageContext)
}
private fun dispatchLegacySkipGesture(parent: ViewGroup?) {
if (parent != null) {
var touchedParent = false
parent.iterateParent {
touchedParent = true
it.triggerCloseTouchEvent()
false
}
if (touchedParent) return
}
context.mainActivity
?.findViewById<View>(android.R.id.content)
?.triggerCloseTouchEvent()
}
private fun dispatchForwardHotZoneTap(target: View?, xFraction: Float) {
target?.triggerCloseTouchEventAtFraction(xFraction = xFraction, yFraction = 0.5f)
}
private suspend fun skipMarkedSnap(
parent: ViewGroup?,
mediaDownloader: MediaDownloader,
originalMessageContext: OperaViewerMessageContext
) {
val contentView = context.mainActivity?.findViewById<ViewGroup>(android.R.id.content)
val skipAttempts = listOf<suspend () -> Unit>(
{
dispatchLegacySkipGesture(parent)
},
{
dispatchForwardHotZoneTap(findVisibleModernViewerContainer() ?: contentView, 0.88f)
},
{
dispatchForwardHotZoneTap(contentView ?: findVisibleModernViewerContainer(), 0.88f)
},
{
val target = findVisibleModernViewerContainer() ?: contentView
dispatchForwardHotZoneTap(target, 0.88f)
delay(55)
if (!hasViewerAdvanced(mediaDownloader, originalMessageContext)) {
dispatchForwardHotZoneTap(target, 0.94f)
}
}
)
for ((index, attempt) in skipAttempts.withIndex()) {
attempt()
if (waitForViewerAdvance(mediaDownloader, originalMessageContext, if (index == 0) 120L else 180L)) {
return
}
}
}
private fun Class<*>?.hasNameSuffixInHierarchy(suffix: String): Boolean {
@@ -326,7 +417,8 @@ class OperaViewerIcons : AbstractMenu() {
}
private suspend fun markCurrentSnapAsSeen(parent: ViewGroup?) {
val messageContext = resolveCurrentMessageContext(context.feature(MediaDownloader::class)) ?: return
val mediaDownloader = context.feature(MediaDownloader::class)
val messageContext = resolveCurrentMessageContext(mediaDownloader) ?: return
val result = context.feature(AutoMarkAsRead::class).markSnapAsSeen(
messageContext.conversationId,
messageContext.clientMessageId
@@ -335,16 +427,7 @@ class OperaViewerIcons : AbstractMenu() {
if (result == "DUPLICATEREQUEST" || result == null) {
if (context.config.messaging.skipWhenMarkingAsSeen.get()) {
withContext(Dispatchers.Main) {
if (parent != null) {
parent.iterateParent {
it.triggerCloseTouchEvent()
false
}
} else {
context.mainActivity
?.findViewById<View>(android.R.id.content)
?.triggerCloseTouchEvent()
}
skipMarkedSnap(parent, mediaDownloader, messageContext)
}
}
}

View File

@@ -19,7 +19,36 @@ class MediaInfo(obj: Any?) : AbstractWrapper(obj) {
if (it.isEmpty()) {
throw RuntimeException("MediaInfo is empty")
}
instance = it[0]!!
// Select highest quality media by comparing width * height
// Use explicit field name search to avoid relying on field order
instance = it.filterNotNull().maxByOrNull { mediaObj ->
runCatching {
val fields = mediaObj.javaClass.fields
// Search for width and height fields by name (case-insensitive)
// Common patterns: "width", "mWidth", "height", "mHeight"
val widthField = fields.find { f ->
f.name.equals("width", ignoreCase = true) ||
f.name.equals("mWidth", ignoreCase = true)
}
val heightField = fields.find { f ->
f.name.equals("height", ignoreCase = true) ||
f.name.equals("mHeight", ignoreCase = true)
}
// Validate fields exist and are integers before calculating resolution
if (widthField != null && heightField != null &&
(widthField.type == Int::class.javaPrimitiveType || widthField.type == Int::class.java) &&
(heightField.type == Int::class.javaPrimitiveType || heightField.type == Int::class.java)) {
widthField.isAccessible = true
heightField.isAccessible = true
widthField.getInt(mediaObj) * heightField.getInt(mediaObj)
} else {
0
}
}.getOrDefault(0)
} ?: it.filterNotNull().firstOrNull() ?: it.firstOrNull()
}
}
}

View File

@@ -7,10 +7,11 @@ org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn
nativeAbis=arm64-v8a
APP_VERSION_NAME=1.4.6
APP_VERSION_CODE=286
APP_VERSION_NAME=1.6.0
APP_VERSION_CODE=310
debug_build_hash=18fe2a814d0e2eb5
psIntegrityPinnedSha256=
EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c
android.disallowKotlinSourceSets=false
android.sourceset.disallowProvider=false
ksp.incremental=false

View File

@@ -1,31 +1,70 @@
use std::{ffi::{CStr, CString}, fs};
use std::{cell::Cell, ffi::{CStr, CString}};
use nix::libc::{self, c_uint};
use crate::{config, def_hook, dobby_hook_sym};
thread_local! {
static FONT_REDIRECT_IN_PROGRESS: Cell<bool> = const { Cell::new(false) };
}
fn should_redirect_font(pathname: &str) -> bool {
let normalized = pathname.replace('\\', "/");
let file_name = normalized.rsplit('/').next().unwrap_or(&normalized).to_ascii_lowercase();
let is_font_file = file_name.ends_with(".ttf")
|| file_name.ends_with(".ttc")
|| file_name.ends_with(".otf");
let is_system_font_path = normalized.starts_with("/system/fonts/")
|| normalized.starts_with("/product/fonts/")
|| normalized.starts_with("/system_ext/fonts/")
|| normalized.starts_with("/vendor/fonts/");
is_system_font_path && is_font_file && (
file_name.contains("emoji")
|| file_name == "noto_color_emoji.ttf"
|| file_name == "samsungcoloremoji.ttf"
)
}
fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option<i32> {
let font_path = config::native_config().custom_emoji_font_path.clone()?;
match CString::new(font_path.clone()) {
Ok(c_font_path) => {
let fd = FONT_REDIRECT_IN_PROGRESS.with(|guard| {
let was_active = guard.replace(true);
let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const u8, flags, mode) };
guard.set(was_active);
fd
});
if fd >= 0 {
debug!("redirected emoji font open to {}", font_path);
Some(fd)
} else {
debug!("failed to open custom emoji font path (fd={}): {}", fd, font_path);
None
}
}
Err(_) => {
warn!("custom emoji font path contains null byte, using fallback system font");
None
}
}
}
def_hook!(
open_hook,
i32,
|path: *const u8, flags: i32, mode: c_uint| {
if let Ok(pathname) = CStr::from_ptr(path).to_str() {
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() {
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);
if FONT_REDIRECT_IN_PROGRESS.with(|guard| guard.get()) {
return open_hook_original.unwrap()(path, flags, mode);
}
if !path.is_null() {
if let Ok(pathname) = CStr::from_ptr(path).to_str() {
if should_redirect_font(pathname) {
if let Some(fd) = open_custom_font_fd(flags, mode) {
return fd;
}
}
}
@@ -35,7 +74,6 @@ def_hook!(
}
);
pub fn init() {
if config::native_config().custom_emoji_font_path.is_none() {
return;