Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33664c1112 | ||
|
|
bb8e644b4c | ||
|
|
21fba253b1 | ||
|
|
9e5b318120 | ||
|
|
00c5d60b7b | ||
|
|
21cd306132 | ||
|
|
0d42aed0ff | ||
|
|
f8fdd1893f | ||
|
|
a8e2148b26 | ||
|
|
1e9ad8eb2b | ||
|
|
182e7eefeb | ||
|
|
070a8ffaf7 | ||
|
|
95f98221e3 | ||
|
|
dbbc52b67f | ||
|
|
b7c8042a93 | ||
|
|
141b5e16a0 | ||
|
|
901e4f81b8 | ||
|
|
a4e86b5ab4 | ||
|
|
bdc6d12739 | ||
|
|
6c8d5297c8 | ||
|
|
7a8ad81d48 | ||
|
|
7db60d6eb1 | ||
|
|
565a8b8287 | ||
|
|
1c612ebc8e | ||
|
|
8db363a8f0 | ||
|
|
4fab5cc4ab | ||
|
|
a7a15702f3 | ||
|
|
04aaefc748 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ security/allowed_codes.local.*
|
||||
valdi/node_modules/
|
||||
hs_err_pid*.log
|
||||
replay_pid*.log
|
||||
.vs
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package me.eternal.purrfectsnap.bridge;
|
||||
|
||||
interface AutoOpenInterface {
|
||||
int getProcessedCount();
|
||||
List<String> getQueueItems(); // returns JSON serialized SnapQueueItem list
|
||||
void reset();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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+")) } }
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -114,6 +114,10 @@ class FeatureManager(
|
||||
HideFriendFeedEntry(),
|
||||
RequerySqlite(),
|
||||
RefreshFriendSuggestions(),
|
||||
LocalPinnedMessages(),
|
||||
BlockCalls(),
|
||||
CallMetadataNotifier(),
|
||||
ConversationSoundEffects(),
|
||||
CallButtonsOverride(),
|
||||
SnapPreview(),
|
||||
BypassScreenshotDetection(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user