From 279e847d003624a0a149c883a37ce9de5889211c Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:07:06 +0530 Subject: [PATCH 1/5] Auto Open Engine refactor and Media Downloader stabilization. --- .../download/DownloadProcessor.kt | 37 +- .../purrfectsnap/download/FFMpegProcessor.kt | 78 +- .../task/AnnouncementCheckWorker.kt | 3 +- .../ui/manager/pages/home/HomeLogs.kt | 40 + .../pages/themes/aphelion/AphelionLogsView.kt | 77 +- .../pages/themes/legacy/LegacyTheme.kt | 68 + common/src/main/assets/lang/en_US.json | 29 +- .../common/config/impl/DownloaderConfig.kt | 6 +- .../common/config/impl/Experimental.kt | 1 + .../common/config/impl/MessagingTweaks.kt | 8 +- .../common/scripting/ScriptRuntime.kt | 2 +- .../util/ktx/AndroidCompatExtensions.kt | 14 +- .../eternal/purrfectsnap/core/ModContext.kt | 8 +- .../eternal/purrfectsnap/core/PurrfectSnap.kt | 1 + .../impl/downloader/MediaDownloader.kt | 1227 +++++------------ .../impl/experiments/AutoOpenSnaps.kt | 782 +++++------ .../features/impl/messaging/Notifications.kt | 19 +- .../features/impl/ui/ConversationToolbox.kt | 1 + .../core/scripting/CoreScriptRuntime.kt | 32 +- .../core/ui/menu/impl/FriendFeedInfoMenu.kt | 1 + .../core/wrapper/impl/media/opera/ParamMap.kt | 32 + native/rust/Cargo.lock | 36 +- native/rust/src/config.rs | 6 + native/rust/src/modules/custom_font_hook.rs | 34 +- native/rust/src/modules/util/valdi_utils.rs | 22 +- native/rust/src/modules/valdi_hook.rs | 137 +- .../purrfectsnap/nativelib/NativeConfig.kt | 9 + 27 files changed, 1205 insertions(+), 1505 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt index b5c43e64..6a1a7d84 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt @@ -648,8 +648,41 @@ class DownloadProcessor ( val media = downloadedMedias.entries.first { !it.key.isOverlay }.value val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value - val renamedMedia = renameFromFileType(media, FileType.fromFile(media)) - val renamedOverlayMedia = renameFromFileType(overlayMedia, FileType.fromFile(overlayMedia)) + val mediaFileType = FileType.fromFile(media) + val overlayFileType = FileType.fromFile(overlayMedia) + + val renamedMedia = renameFromFileType(media, mediaFileType) + val renamedOverlayMedia = renameFromFileType(overlayMedia, overlayFileType) + + if (mediaFileType.isImage && overlayFileType.isImage) { + runCatching { + callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension)) + val originalBitmap = BitmapFactory.decodeFile(renamedMedia.absolutePath) ?: throw Exception("Failed to decode original image") + val overlayBitmap = BitmapFactory.decodeFile(renamedOverlayMedia.absolutePath) ?: throw Exception("Failed to decode overlay image") + + val mergedBitmap = me.eternal.purrfectsnap.core.util.media.PreviewUtils.mergeBitmapOverlay(originalBitmap, overlayBitmap) + val mergedImage: File = File.createTempFile("merged", "." + (mediaFileType.fileExtension ?: "jpg")) + + val compressFormat = when (mediaFileType) { + FileType.PNG -> Bitmap.CompressFormat.PNG + FileType.WEBP -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP + else -> Bitmap.CompressFormat.JPEG + } + + mergedImage.outputStream().use { + mergedBitmap.compress(compressFormat, 100, it) + } + + saveMediaToGallery(pendingTask, mergedImage, downloadMetadata) + mergedImage.delete() + renamedOverlayMedia.delete() + renamedMedia.delete() + return@launch + }.onFailure { + remoteSideContext.log.error("Failed to merge image overlay using Bitmap, falling back to FFmpeg", it) + } + } + val mergedOverlay: File = File.createTempFile("merged", ".mp4") runCatching { callbackOnProgress(translation.format("processing_toast", "path" to media.nameWithoutExtension)) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt index 69762b99..86c0b053 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -90,6 +90,8 @@ class FFMpegProcessor( ) + private val sharedExecutor = Executors.newSingleThreadExecutor() + private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine { val stringBuilder = StringBuilder() arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> @@ -127,7 +129,7 @@ class FFMpegProcessor( Level.AV_LOG_VERBOSE -> LogLevel.VERBOSE else -> return@logFunction }, log.message) - }, { onStatistics(it) }, Executors.newSingleThreadExecutor()) + }, { onStatistics(it) }, sharedExecutor) } suspend fun execute(args: Request) { @@ -162,7 +164,7 @@ class FFMpegProcessor( } Action.MERGE_OVERLAY -> { inputArguments += "-i" to args.overlay!!.absolutePath - outputArguments += "-filter_complex" to "\"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink;[img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\"" + outputArguments += "-filter_complex" to "\"[1:v][0:v]scale2ref=w=iw:h=ih[ovrl][main];[main][ovrl]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw/2):2*trunc(ih/2)\"" } Action.CONVERSION -> { if (ffmpegOptions.customAudioCodec.isEmpty()) { @@ -187,45 +189,47 @@ class FFMpegProcessor( }.getOrNull()?.let { file to it } } - val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) -> - r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 - }?.let { (_, r) -> - r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to - r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() - } ?: throw Exception("Failed to get video size") + try { + val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) -> + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 + }?.let { (_, r) -> + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() + } ?: throw Exception("Failed to get video size") - val filterFirstPart = StringBuilder() - val filterSecondPart = StringBuilder() - var containsNoSound = false + val filterFirstPart = StringBuilder() + val filterSecondPart = StringBuilder() + var containsNoSound = false - filesInfo.forEachIndexed { index, (file, retriever) -> - filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];") - if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") { - filterSecondPart.append("[v$index][$index:a]") - } else { - containsNoSound = true - filterSecondPart.append("[v$index][${filesInfo.size}]") + filesInfo.forEachIndexed { index, (file, retriever) -> + filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];") + if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") { + filterSecondPart.append("[v$index][$index:a]") + } else { + containsNoSound = true + filterSecondPart.append("[v$index][${filesInfo.size}]") + } + inputArguments += "-i" to file } - inputArguments += "-i" to file + + if (containsNoSound) { + inputArguments += "-f" to "lavfi" + inputArguments += "-t" to "0.1" + inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100" + } + + if (outputArguments["-c:a"] == "copy") { + outputArguments -= "-c:a" + } + + outputArguments += "-fps_mode" to "vfr" + + outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\"" + outputArguments += "-map" to "\"[aout]\"" + outputArguments += "-map" to "\"[vout]\"" + } finally { + filesInfo.forEach { it.second.close() } } - - if (containsNoSound) { - inputArguments += "-f" to "lavfi" - inputArguments += "-t" to "0.1" - inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100" - } - - if (outputArguments["-c:a"] == "copy") { - outputArguments -= "-c:a" - } - - outputArguments += "-fps_mode" to "vfr" - - outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${filesInfo.size}:v=1:a=1[vout][aout]\"" - outputArguments += "-map" to "\"[aout]\"" - outputArguments += "-map" to "\"[vout]\"" - - filesInfo.forEach { it.second.close() } } Action.DOWNLOAD_AUDIO_STREAM -> { outputArguments.clear() diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt index 3a611432..e716ebb3 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/task/AnnouncementCheckWorker.kt @@ -86,7 +86,8 @@ class AnnouncementCheckWorker( val pendingIntent = PendingIntent.getActivity(appContext, 0, intent, PendingIntent.FLAG_IMMUTABLE) val builder = NotificationCompat.Builder(appContext, channelId) - .setSmallIcon(R.mipmap.ic_launcher) + .setSmallIcon(R.mipmap.ic_launcher_monochrome) + .setLargeIcon(android.graphics.BitmapFactory.decodeResource(appContext.resources, R.mipmap.ic_launcher)) .setContentTitle(title) .setContentText(text) .setPriority(NotificationCompat.PRIORITY_DEFAULT) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt index f74d02c5..5b83f12f 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/home/HomeLogs.kt @@ -36,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Report @@ -170,6 +171,7 @@ class HomeLogs : Routes.Route() { internal fun LogsFloatingBar( isRefreshing: Boolean, onRefresh: () -> Unit, + onFilter: () -> Unit, onExport: () -> Unit, onClear: () -> Unit ) { @@ -222,6 +224,20 @@ class HomeLogs : Routes.Route() { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + if (isRefreshing) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = Color.White + ) + } + IconButton(onClick = onFilter) { + Icon( + imageVector = Icons.Filled.FilterList, + contentDescription = "Filter Logs", + tint = PurrfectPalette.glowSecondary + ) + } IconButton(onClick = onRefresh, enabled = !isRefreshing) { Icon( imageVector = Icons.Filled.Refresh, @@ -457,7 +473,31 @@ class HomeLogs : Routes.Route() { LogLevel.WARN -> Icons.Outlined.Warning } + enum class LogCategory(val translationKey: String, val tags: List) { + CORE("log_category_core", listOf("core", "hook", "module", "mappings")), + AUTO_OPEN("log_category_auto_open", listOf("autoopenengine", "autoopen")), + MEDIA("log_category_media", listOf("downloader", "ffmpeg", "media", "video")), + BRIDGE("log_category_bridge", listOf("messagingbridge", "bridge", "ipc")), + SYSTEM("log_category_system", listOf("systemguard", "thermal", "battery", "wakelock")), + TRACKER("log_category_tracker", listOf("tracker", "friendtracker")) + } + + val enabledCategories = mutableStateMapOf().apply { + LogCategory.entries.forEach { put(it, true) } + } + + internal fun getCategoryForLog(line: LogLine): LogCategory? { + val tag = line.tag.lowercase() + val message = line.message.lowercase() + return LogCategory.entries.find { category -> + category.tags.any { tag.contains(it) || message.contains("[$it]") } + } + } + internal fun shouldHideLog(line: LogLine): Boolean { + val category = getCategoryForLog(line) + if (category != null && enabledCategories[category] == false) return true + val message = line.message.lowercase() val tag = line.tag.lowercase() return message.startsWith("blocked ep") || diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt index e50317cb..32ada622 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/aphelion/AphelionLogsView.kt @@ -2,27 +2,31 @@ package me.eternal.purrfectsnap.ui.manager.pages.themes.aphelion import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.DeleteSweep -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* 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.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight 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.navigation.NavBackStackEntry import me.eternal.purrfectsnap.ui.manager.components.FloatingTopBar import me.eternal.purrfectsnap.ui.manager.pages.home.HomeLogs import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette +import me.eternal.purrfectsnap.core.ui.PurrfectGlassCard +import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme import me.eternal.purrfectsnap.ui.util.headerHeightTracker import me.eternal.purrfectsnap.ui.util.Motion import kotlinx.coroutines.launch @@ -37,6 +41,7 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { var logReader by remember { mutableStateOf(null) } val visibleLogs = remember { mutableStateListOf() } var isRefreshing by remember { mutableStateOf(false) } + var showFilterDialog by remember { mutableStateOf(false) } fun refreshLogs() { isRefreshing = true @@ -69,6 +74,67 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { } } + @Composable + fun LogFilterDialog() { + Dialog(onDismissRequest = { showFilterDialog = false }) { + PurrfectOverlayTheme { + PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HomeLogs.LogCategory.entries.forEach { category -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + enabledCategories.keys.forEach { enabledCategories[it] = false } + enabledCategories[category] = true + refreshLogs() + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Checkbox( + checked = enabledCategories[category] == true, + onCheckedChange = { checked -> + enabledCategories[category] = checked + refreshLogs() + }, + colors = CheckboxDefaults.colors( + checkedColor = PurrfectPalette.glowPrimary, + uncheckedColor = Color.White.copy(alpha = 0.4f), + checkmarkColor = Color.White + ) + ) + Text( + text = translation[category.translationKey] ?: category.name, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button( + onClick = { showFilterDialog = false }, + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary) + ) { + Text(translation["filter_logs_done_button"] ?: "Done") + } + } + } + } + } + } + } + + if (showFilterDialog) { + LogFilterDialog() + } + LaunchedEffect(externalRefreshTick.value) { if (externalRefreshTick.value > 0) { refreshLogs() @@ -132,6 +198,9 @@ fun HomeLogs.AphelionLogsScreen(nav: NavBackStackEntry) { color = Color.White ) } + IconButton(onClick = { showFilterDialog = true }) { + Icon(Icons.Filled.FilterList, contentDescription = "Filter Logs", tint = PurrfectPalette.glowSecondary) + } IconButton(onClick = { refreshLogs() }) { Icon(Icons.Filled.Refresh, contentDescription = "Refresh", tint = Color.White) } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt index db8b2942..dff2aca6 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/themes/legacy/LegacyTheme.kt @@ -1121,6 +1121,8 @@ object LegacyTheme : ThemeContract { val visibleLogs = remember { mutableStateListOf() } val mainExecutor = remember { context.androidContext.mainExecutor } var isRefreshing by remember { mutableStateOf(false) } + var showFilterDialog by remember { mutableStateOf(false) } + fun refreshLogs() { coroutineScope.launch { val readerResult = withContext(Dispatchers.IO) { @@ -1154,6 +1156,71 @@ object LegacyTheme : ThemeContract { isRefreshing = false } } + + @Composable + fun LogFilterDialog() { + androidx.compose.ui.window.Dialog(onDismissRequest = { showFilterDialog = false }) { + me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme { + me.eternal.purrfectsnap.core.ui.PurrfectGlassCard(title = translation["filter_logs_title"] ?: "Filter Log Categories", modifier = Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HomeLogs.LogCategory.entries.forEach { category -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + // Solo Focus Logic: Tap the name to filter only this category + enabledCategories.keys.forEach { enabledCategories[it] = false } + enabledCategories[category] = true + isRefreshing = true + refreshLogs() + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Checkbox( + checked = enabledCategories[category] == true, + onCheckedChange = { checked -> + enabledCategories[category] = checked + isRefreshing = true + refreshLogs() + }, + colors = CheckboxDefaults.colors( + checkedColor = PurrfectPalette.glowPrimary, + uncheckedColor = Color.White.copy(alpha = 0.4f), + checkmarkColor = Color.White + ) + ) + Text( + text = translation[category.translationKey] ?: category.name, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button( + onClick = { showFilterDialog = false }, + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurrfectPalette.glowPrimary) + ) { + Text(translation["filter_logs_done_button"] ?: "Done") + } + } + } + } + } + } + } + + if (showFilterDialog) { + LogFilterDialog() + } + LaunchedEffect(externalRefreshTick.intValue) { if (externalRefreshTick.intValue > 0) { isRefreshing = true @@ -1181,6 +1248,7 @@ object LegacyTheme : ThemeContract { isRefreshing = true refreshLogs() }, + onFilter = { showFilterDialog = true }, onExport = { exportLogs() }, onClear = { clearLogsAndReload() } ) diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 3cbbacd6..07bd00cc 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -256,6 +256,15 @@ "home_logs": { "no_logs_hint": "No logs available", "refresh_hint": "Pull to refresh or trigger an action to see new entries.", + "filter_logs_title": "Filter Log Categories", + "filter_logs_menu_item": "Filter Logs", + "filter_logs_done_button": "Done", + "log_category_core": "Core", + "log_category_auto_open": "Auto-Open", + "log_category_media": "Media", + "log_category_bridge": "Bridge", + "log_category_system": "System", + "log_category_tracker": "Tracker", "clear_logs_button": "Clear Logs", "export_logs_button": "Export Logs", "saving_logs_toast": "Saving logs, this may take a while ...", @@ -1676,6 +1685,14 @@ "name": "Allow Running in Background", "description": "Allows Auto Open Snaps to run in the background. Note: This will significantly drain your battery" }, + "delay_between_snaps": { + "name": "Delay Between Snaps", + "description": "The delay in milliseconds between opening each individual Snap" + }, + "delay_between_conversations": { + "name": "Delay Between Conversations", + "description": "The delay in milliseconds when switching to open Snaps from a different conversation" + }, "min_delay": { "name": "Min Delay (ms)", "description": "Minimum delay in milliseconds before opening a snap" @@ -2165,6 +2182,10 @@ "name": "Disable Bitmoji", "description": "Disables Friends Profile Bitmoji" }, + "debug_font_redirect": { + "name": "Debug Native Font Redirect", + "description": "Logs native font interception. For developer use only." + }, "custom_emoji_font": { "name": "Custom Emoji Font", "description": "Allows you to use a custom emoji font. Only works with .ttf fonts" @@ -3725,6 +3746,7 @@ "snap_item": "Snap {index} of {total}" }, "batch_download_complete_toast": "All snaps downloaded", + "batch_progress_toast": "Downloading {current}/{total}", "batch_download_jump_failed_toast": "Could not navigate to next snap. Ensure Story Snap Jump is enabled and the story view is visible." }, "streaks_reminder": { @@ -4385,8 +4407,7 @@ "deepseek": "DeepSeek", "openai": "OpenAI", "openrouter": "OpenRouter" - } - , + }, "tasks_no_tasks": "No tasks", "tasks_no_active_tasks": "No active tasks", "tasks_no_scheduled_tasks": "No scheduled snaps", @@ -4395,8 +4416,8 @@ "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_summary_active": "{active} active \u2022 {recent} recent", + "tasks_summary_idle": "Idle \u2022 {recent} recent", "tasks_running_count": "{count} running", "tasks_tagline": "Monitor and manage background actions", "tasks_failed_to_open_file": "Failed to open file", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt index 6ca1eeb4..cc832348 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/DownloaderConfig.kt @@ -9,9 +9,9 @@ class DownloaderConfig : ConfigContainer() { val threads = integer("threads", 4) // Bump Default Value to 4 Tested on Pixel 5 (Qualcomm Snapdragon 765G) Had no lag val preset = unique("preset", "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow") { addFlags(ConfigFlag.NO_TRANSLATE) - } - val constantRateFactor = integer("constant_rate_factor", 30) - val videoBitrate = integer("video_bitrate", 5000) + }.apply { set("veryfast") } + val constantRateFactor = integer("constant_rate_factor", 22) + val videoBitrate = integer("video_bitrate", 8000) val audioBitrate = integer("audio_bitrate", 128) val customVideoCodec = string("custom_video_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } val customAudioCodec = string("custom_audio_codec") { addFlags(ConfigFlag.NO_TRANSLATE) } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt index ec8b581b..e5bec0fd 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Experimental.kt @@ -35,6 +35,7 @@ class Experimental : ConfigContainer() { class NativeHooks : ConfigContainer() { val valdiHooks = container("composer_hooks", ValdiHooksConfig()) { requireRestart() } val disableBitmoji = boolean("disable_bitmoji") + val debugFontRedirect = boolean("debug_font_redirect") { addFlags(ConfigFlag.HIDDEN) } val customEmojiFont = string("custom_emoji_font") { requireRestart() addFlags(ConfigFlag.USER_IMPORT) diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt index 95533ed8..30ff3532 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/MessagingTweaks.kt @@ -166,12 +166,18 @@ class MessagingTweaks : ConfigContainer() { val maxDelayMs = integer("max_delay_ms", defaultValue = 100) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null && it.toInt() > minDelay.get() } } - val queueSize = integer("queue_size", defaultValue = 1000) { + val queueSize = integer("queue_size", defaultValue = 700) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null } } val retryAttempts = integer("retry_attempts", defaultValue = 5) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(1) != null } } + val delayBetweenSnaps = integer("delay_between_snaps", defaultValue = 100) { + inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null } + } + val delayBetweenConversations = integer("delay_between_conversations", defaultValue = 500) { + inputCheck = { it.toIntOrNull()?.coerceAtLeast(0) != null } + } val retryDelay = integer("retry_delay", defaultValue = 3000) { inputCheck = { it.toIntOrNull()?.coerceAtLeast(1000) != null } } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/scripting/ScriptRuntime.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/scripting/ScriptRuntime.kt index c0fd0d53..49303cb8 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/scripting/ScriptRuntime.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/scripting/ScriptRuntime.kt @@ -22,7 +22,7 @@ open class ScriptRuntime( private val modules = mutableMapOf() - fun eachModule(f: JSModule.() -> Unit) { + open fun eachModule(f: JSModule.() -> Unit) { modules.values.forEach { module -> runCatching { module.f() diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/util/ktx/AndroidCompatExtensions.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/util/ktx/AndroidCompatExtensions.kt index 0ba83ca8..4fc04daf 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/util/ktx/AndroidCompatExtensions.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/util/ktx/AndroidCompatExtensions.kt @@ -59,12 +59,14 @@ fun InputStream.toParcelFileDescriptor(coroutineScope: CoroutineScope): ParcelFi val fos = ParcelFileDescriptor.AutoCloseOutputStream(pfd[1]) coroutineScope.launch(Dispatchers.IO) { - try { - copyTo(fos) - } finally { - close() - fos.flush() - fos.close() + runCatching { + try { + copyTo(fos) + } finally { + close() + fos.flush() + fos.close() + } } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ModContext.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ModContext.kt index 8ed072cd..8386662b 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ModContext.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ModContext.kt @@ -164,10 +164,10 @@ class ModContext( disableMetrics = config.global.disableMetrics.get(), valdiHooks = config.experimental.nativeHooks.valdiHooks.globalState == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q, - customEmojiFontPath = getCustomEmojiFontPath(this) - ) - ) - } + customEmojiFontPath = getCustomEmojiFontPath(this), + debugFontRedirect = config.experimental.nativeHooks.debugFontRedirect.get() + ) + ) } fun getConfigLocale(): String { return _config.locale diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt index b7a33662..cfc03f79 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/PurrfectSnap.kt @@ -1,5 +1,6 @@ package me.eternal.purrfectsnap.core +import me.eternal.purrfectsnap.common.scripting.JSModule import android.app.Activity import android.content.Context import android.content.Intent diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 8f91aeb6..9d0b76b5 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -4,9 +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 import android.widget.LinearLayout import android.widget.ProgressBar @@ -14,11 +12,9 @@ import android.widget.TextView import androidx.compose.foundation.background 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 @@ -29,27 +25,19 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.runtime.getValue 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.unit.dp -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay 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 -import me.eternal.purrfectsnap.core.ui.PurrfectOverlayTheme -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.Dispatchers import me.eternal.purrfectsnap.bridge.DownloadCallback import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.FileType @@ -57,19 +45,22 @@ import me.eternal.purrfectsnap.common.data.MessagingRuleType import me.eternal.purrfectsnap.common.data.download.* import me.eternal.purrfectsnap.common.database.impl.ConversationMessage import me.eternal.purrfectsnap.common.database.impl.FriendInfo +import me.eternal.purrfectsnap.common.ui.createComposeAlertDialog import me.eternal.purrfectsnap.common.util.ktx.copyToClipboard import me.eternal.purrfectsnap.common.util.ktx.longHashCode import me.eternal.purrfectsnap.common.util.protobuf.ProtoReader import me.eternal.purrfectsnap.common.util.snap.BitmojiSelfie import me.eternal.purrfectsnap.common.util.snap.MediaDownloaderHelper -import me.eternal.purrfectsnap.common.util.snap.RemoteMediaResolver import me.eternal.purrfectsnap.core.DownloadManagerClient import me.eternal.purrfectsnap.core.PurrfectSnap import me.eternal.purrfectsnap.core.features.MessagingRuleFeature import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.DecodedAttachment import me.eternal.purrfectsnap.core.features.impl.downloader.decoder.MessageDecoder import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging -import me.eternal.purrfectsnap.core.features.impl.spying.MessageLogger +import me.eternal.purrfectsnap.core.features.impl.ui.OperaStoryOverlay +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.debugEditText import me.eternal.purrfectsnap.core.util.hook.HookStage @@ -80,35 +71,14 @@ import me.eternal.purrfectsnap.core.util.isSnapchatVersionAtLeast import me.eternal.purrfectsnap.core.util.media.PreviewUtils import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID import me.eternal.purrfectsnap.core.wrapper.impl.media.MediaInfo -import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.LongformVideoPlaylistItem -import me.eternal.purrfectsnap.core.wrapper.impl.media.dash.SnapPlaylistItem import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair -import me.eternal.purrfectsnap.core.features.impl.ui.OperaStoryOverlay -import me.eternal.purrfectsnap.core.wrapper.impl.media.EncryptionWrapper import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper -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.resume import kotlin.coroutines.suspendCoroutine import kotlin.math.absoluteValue -import android.util.Base64 -import javax.crypto.Cipher -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.SecretKeySpec - -class SnapChapterInfo( - val offset: Long, - val duration: Long? -) data class OperaViewerMessageContext( val conversationId: String, @@ -119,35 +89,53 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private var lastSeenMediaInfoMap: MutableMap? = null var lastSeenMapParams: ParamMap? = null private set + @Volatile + private var pendingBatchDownloadIndices: MutableList? = null + @Volatile + private var batchForceAllowDuplicate: Boolean = false private val translations by lazy { - context.translation.getCategory("download_processor") + this@MediaDownloader.context.translation.getCategory("download_processor") } private val useModernOperaViewerContext by lazy { isSnapchatVersionAtLeast( - context.mappings.getSnapchatPackageInfo()?.versionName, + this@MediaDownloader.context.mappings.getSnapchatPackageInfo()?.versionName, SNAPCHAT_13_80_VERSION ) } + private fun logInfo(msg: String) = this@MediaDownloader.context.log.info("[MediaDownloader] $msg") + private fun logVerbose(msg: String) = this@MediaDownloader.context.log.verbose("[MediaDownloader] $msg") + private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@MediaDownloader.context.log.error("[MediaDownloader] $msg", e) else this@MediaDownloader.context.log.error("[MediaDownloader] $msg") + + @Volatile + private var batchTotalCount: Int = 0 + @Volatile + private var batchSuccessCount: Int = 0 + @Volatile + private var batchFailureCount: Int = 0 + @Volatile + private var initialBatchStoryIdentity: String? = null + fun provideDownloadManagerClient( mediaIdentifier: String, mediaAuthor: String, creationTimestamp: Long? = null, downloadSource: MediaDownloadSource, friendInfo: FriendInfo? = null, - forceAllowDuplicate: Boolean = false + forceAllowDuplicate: Boolean = false, + isBatch: Boolean = false ): DownloadManagerClient { + val modCtx = this@MediaDownloader.context val generatedHash = ( - if (!context.config.downloader.allowDuplicate.get() && !forceAllowDuplicate) mediaIdentifier + if (!modCtx.config.downloader.allowDuplicate.get() && !forceAllowDuplicate) mediaIdentifier else UUID.randomUUID().toString() ).longHashCode().absoluteValue.toString(16) val iconUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo?.bitmojiSelfieId, friendInfo?.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) - - val downloadLogging by context.config.downloader.logging + val downloadLogging = modCtx.config.downloader.logging.get() val outputPath = createNewFilePath( - context.config, + modCtx.config, generatedHash.substring(0, generatedHash.length.coerceAtMost(8)), downloadSource, mediaAuthor, @@ -155,18 +143,16 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp ) return DownloadManagerClient( - context = context, + context = modCtx, metadata = DownloadMetadata( mediaIdentifier = generatedHash, mediaAuthor = mediaAuthor, - downloadSource = downloadSource.translate(context.translation), + downloadSource = downloadSource.translate(modCtx.translation), iconUrl = iconUrl, outputPath = outputPath ), callback = object: DownloadCallback.Stub() { override fun onSuccess(outputFile: String) { - if (!downloadLogging.contains("success")) return - var finalOutputFile = outputFile runCatching { val file = java.io.File(outputFile) @@ -176,74 +162,61 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp input.read(buffer) buffer } + val fileType = FileType.fromByteArray(header) if (fileType.isVideo && !outputFile.endsWith(".mp4", ignoreCase = true)) { - val newPath = outputFile.removeSuffix(".dat") + ".mp4" + val newPath = outputFile.removeSuffix(".dat").removeSuffix(".tmp") + ".mp4" val newFile = java.io.File(newPath) if (file.renameTo(newFile)) { finalOutputFile = newPath - context.log.verbose("corrected video extension: $outputFile -> $newPath") + } else { + file.copyTo(newFile, overwrite = true) + file.delete() + finalOutputFile = newPath } } } + }.onFailure { logError("Post-Processing Logic Failed for $outputFile", it) } + + if (isBatch) { + batchSuccessCount++ + if (downloadLogging.contains("success")) { + modCtx.inAppOverlay.showStatusToast( + icon = Icons.Outlined.DownloadDone, + text = translations.format("batch_progress_toast", "current" to (batchSuccessCount + batchFailureCount).toString(), "total" to batchTotalCount.toString()), + durationMs = 1300 + ) + } + return } - context.log.verbose("onSuccess: outputFile=$finalOutputFile") - context.inAppOverlay.showStatusToast( - icon = Icons.Outlined.DownloadDone, - durationMs = 1300, - text = translations["content_saved_toast"].also { - if (context.isMainActivityPaused) { - context.shortToast(it) - } - }, - ) + if (downloadLogging.contains("success")) { + val toastText = translations.format("content_saved_toast", "path" to java.io.File(finalOutputFile).name) + if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.DownloadDone, toastText, 1300) + } } override fun onProgress(message: String) { - if (!downloadLogging.contains("progress")) return - context.log.verbose("onProgress: message=$message") - context.inAppOverlay.showStatusToast( - icon = Icons.Outlined.Info, - durationMs = 1300, - text = message, - ) - if (context.isMainActivityPaused) { - context.shortToast(message) - } + if (isBatch || !downloadLogging.contains("progress")) return + val toastText = message.ifBlank { translations["download_started_toast"] ?: "Started" } + if (modCtx.isMainActivityPaused) modCtx.shortToast(toastText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.Info, toastText, 1300) } override fun onFailure(message: String, throwable: String?) { if (!downloadLogging.contains("failure")) return - context.log.verbose("onFailure: message=$message, throwable=$throwable") - if (context.isMainActivityPaused) { - context.shortToast(message) - } - throwable?.let { t -> - context.inAppOverlay.showStatusToast( - icon = Icons.Outlined.Error, - text = message + t.takeIf { it.isNotEmpty() }?.let { " $it" }.orEmpty(), - ) - return - } - - context.inAppOverlay.showStatusToast( - icon = Icons.Outlined.Warning, - durationMs = 1300, - text = message, - ) + val errorText = translations[if (message == "Failed to download") "failed_generic_toast" else message] ?: message + if (isBatch) { batchFailureCount++; return } + if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText) + modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300) } } ) } - private fun ParamMap.getStorySnapIndex(): Int? = - this["snap_index_in_story"]?.toString()?.toIntOrNull() - ?: this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() - - private fun ParamMap.getStorySnapTotal(): Int? = - this["snap_story_length"]?.toString()?.toIntOrNull() - ?: this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() + private fun ParamMap.getStorySnapIndex(): Int? = this["snap_index_in_story"]?.toString()?.toIntOrNull() ?: this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() + private fun ParamMap.getStorySnapTotal(): Int? = this["snap_story_length"]?.toString()?.toIntOrNull() ?: this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() private fun isMultiSnapStory(paramMap: ParamMap): Boolean { if (paramMap.containsKey("MESSAGE_ID") || paramMap["SNAP_SOURCE"]?.toString() == "SINGLE_SNAP_STORY") return false @@ -252,936 +225,380 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp return total > 1 } - /* - * Download the last seen media - */ fun downloadLastOperaMediaAsync(allowDuplicate: Boolean) { if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return val paramMap = lastSeenMapParams!! val mediaInfoMap = lastSeenMediaInfoMap!! + val modCtx = this@MediaDownloader.context - if (isMultiSnapStory(paramMap) && context.config.downloader.storySnapListDownload.get()) { - context.runOnUiThread { - showStorySnapSelectionDialog(paramMap, mediaInfoMap, allowDuplicate) - } + if (isMultiSnapStory(paramMap) && modCtx.config.downloader.storySnapListDownload.get()) { + modCtx.runOnUiThread { showStorySnapSelectionDialog(paramMap, mediaInfoMap, allowDuplicate) } return } - context.executeAsync { - handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) - } + modCtx.coroutineScope.launch { handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) } } private fun showStorySnapSelectionDialog(paramMap: ParamMap, mediaInfoMap: Map, allowDuplicate: Boolean) { val totalCount = paramMap.getStorySnapTotal() ?: return val currentIndex = paramMap.getStorySnapIndex() ?: 0 - val tr = context.translation.getCategory("download_processor.story_snap_dialog") - val cancelStr = context.translation["button.cancel"] - val downloadStr = context.translation["button.download"] - context.runOnUiThread { - createComposeAlertDialog(context.mainActivity!!) { alertDialog -> + val modCtx = this@MediaDownloader.context + val tr = modCtx.translation.getCategory("download_processor.story_snap_dialog") + val cancelStr = modCtx.translation["button.cancel"] ?: "Cancel" + val downloadStr = modCtx.translation["button.download"] ?: "Download" + + modCtx.runOnUiThread { + val mainActivity = modCtx.mainActivity ?: return@runOnUiThread + createComposeAlertDialog(mainActivity) { alertDialog -> PurrfectOverlayTheme { val selected = remember { mutableStateListOf().apply { add(currentIndex) } } - - PurrfectGlassCard( - title = tr["title"], - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp, max = 320.dp) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { + PurrfectGlassCard(title = tr["title"] ?: "Select", modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp, max = 320.dp).background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)).padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { itemsIndexed((0 until totalCount).toList()) { index, _ -> - val label = tr.format("snap_item", "index" to (index + 1).toString(), "total" to totalCount.toString()) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Checkbox( - checked = selected.contains(index), - onCheckedChange = { checked -> - if (checked) selected.add(index) else selected.remove(index) - }, - colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) - ) - Text( - label, - style = MaterialTheme.typography.bodyMedium, - color = PurrfectOverlayPalette.textPrimary - ) + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = selected.contains(index), onCheckedChange = { if (it) selected.add(index) else selected.remove(index) }, colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)) + Text(tr.format("snap_item", "index" to (index + 1).toString(), "total" to totalCount.toString()), style = MaterialTheme.typography.bodyMedium, color = PurrfectOverlayPalette.textPrimary) } } } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = selected.size == totalCount, - onCheckedChange = { checked -> - if (checked) { - selected.clear() - selected.addAll(0 until totalCount) - } else { - selected.clear() - } - }, - colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) - ) - Text( - tr["select_all"], - style = MaterialTheme.typography.bodyMedium, - color = PurrfectOverlayPalette.textPrimary - ) + Checkbox(checked = selected.size == totalCount, onCheckedChange = { if (it) { selected.clear(); selected.addAll(0 until totalCount) } else selected.clear() }, colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary)) + Text(tr["select_all"] ?: "Select All", style = MaterialTheme.typography.bodyMedium, color = PurrfectOverlayPalette.textPrimary) } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - onClick = { alertDialog.dismiss() }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = PurrfectOverlayPalette.textPrimary) - ) { - Text(cancelStr) - } - Button( - onClick = { - if (!selected.contains(currentIndex)) return@Button - context.executeAsync { - runCatching { handleOperaMedia(paramMap, mediaInfoMap, true, allowDuplicate) } - .onFailure { - context.log.error("Story download failed", it) - context.shortToast(translations["failed_generic_toast"]) - } - } - alertDialog.dismiss() - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary) - ) { - Text(downloadStr) - } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = { alertDialog.dismiss() }, modifier = Modifier.weight(1f), shape = RoundedCornerShape(14.dp)) { Text(cancelStr) } + Button(onClick = { if (selected.isNotEmpty()) { startBatchDownload(selected.sorted().toMutableList(), allowDuplicate); alertDialog.dismiss() } }, modifier = Modifier.weight(1f), shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary)) { Text(downloadStr) } } } } } - }.apply { - window?.setBackgroundDrawableResource(android.R.color.transparent) - show() + }.apply { window?.setBackgroundDrawableResource(android.R.color.transparent); show() } + } + } + + private fun startBatchDownload(indices: MutableList, allowDuplicate: Boolean) { + if (indices.isEmpty()) return + val paramMap = lastSeenMapParams ?: return + val mediaInfoMap = lastSeenMediaInfoMap ?: return + val modCtx = this@MediaDownloader.context + + batchTotalCount = indices.size + batchSuccessCount = 0; batchFailureCount = 0 + pendingBatchDownloadIndices = indices + batchForceAllowDuplicate = allowDuplicate + initialBatchStoryIdentity = paramMap.getStoryIdentity() + + val currentIndex = paramMap.getStorySnapIndex() ?: 0 + val targetIndex = indices.first() + val totalCount = paramMap.getStorySnapTotal() + + if (currentIndex == targetIndex) { + modCtx.coroutineScope.launch { processNextBatchDownload(paramMap, mediaInfoMap) } + } else { + val jumped = modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount) + if (!jumped) { pendingBatchDownloadIndices = null; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } + } + } + + private suspend fun downloadSingleSnap(paramMap: ParamMap, mediaInfoMap: Map) { + runCatching { + handleOperaMedia(paramMap, mediaInfoMap, forceDownload = true, forceAllowDuplicate = batchForceAllowDuplicate, isBatch = true) + }.onFailure { + batchFailureCount++ + if (batchSuccessCount + batchFailureCount == batchTotalCount) flushPendingMergeAndComplete() + } + } + + private suspend fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map) { + val queue = pendingBatchDownloadIndices ?: return + if (queue.isEmpty()) return + val modCtx = this@MediaDownloader.context + + val currentIdentity = paramMap.getStoryIdentity() + if (initialBatchStoryIdentity != null && (currentIdentity == null || currentIdentity != initialBatchStoryIdentity)) { + flushPendingMergeAndComplete(); return + } + + val currentIndex = paramMap.getStorySnapIndex() ?: -1 + if (currentIndex != queue.first()) return + + queue.removeAt(0) + downloadSingleSnap(paramMap, mediaInfoMap) + + if (queue.isNotEmpty()) { + val totalCount = paramMap.getStorySnapTotal() + modCtx.runOnUiThread { + fun tryJump(retryCount: Int = 0) { + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + val jumped = runCatching { modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) }.getOrNull() == true + if (!jumped && retryCount < 1) tryJump(retryCount + 1) + else if (!jumped) { pendingBatchDownloadIndices = null; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } + }, if (retryCount == 0) 120L else 220L) + } + tryJump() } } } + private fun flushPendingMergeAndComplete() { + val modCtx = this@MediaDownloader.context + pendingBatchDownloadIndices = null + modCtx.shortToast(if (batchFailureCount == 0) translations["batch_download_complete_toast"] ?: "Batch Complete" else "Batch complete: $batchSuccessCount succeeded, $batchFailureCount failed") + } + fun showLastOperaDebugMediaInfo() { if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return - - context.runOnUiThread { + val modCtx = this@MediaDownloader.context + modCtx.runOnUiThread { + val mainActivity = modCtx.mainActivity ?: return@runOnUiThread val mediaInfoText = lastSeenMapParams?.concurrentHashMap?.map { (key, value) -> - val transformedValue = value.let { - if (it::class.java == PurrfectSnap.classCache.snapUUID) { - SnapUUID(it).toString() - } - it - } + val transformedValue = if (value != null && value::class.java == PurrfectSnap.classCache.snapUUID) SnapUUID(value).toString() else value "- $key: $transformedValue" }?.joinToString("\n") ?: "No media info found" - - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply { + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { setTitle("Debug Media Info") - setView(debugEditText(context, mediaInfoText)) - setNeutralButton("Copy") { _, _ -> - context.copyToClipboard(mediaInfoText) - } + setView(debugEditText(modCtx.androidContext, mediaInfoText)) + setNeutralButton("Copy") { _, _ -> modCtx.androidContext.copyToClipboard(mediaInfoText) } setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() } }.show() } } - private fun isSnapContentType(contentTypeId: Int): Boolean { - return when (ContentType.fromId(contentTypeId)) { - ContentType.SNAP, - ContentType.TINY_SNAP, - ContentType.EXTERNAL_MEDIA -> true - else -> false - } + private fun isSnapContentType(contentTypeId: Int): Boolean = when (ContentType.fromId(contentTypeId)) { + ContentType.SNAP, ContentType.TINY_SNAP, ContentType.EXTERNAL_MEDIA -> true + else -> false } private fun validateViewerMessageContext(messageContext: OperaViewerMessageContext): OperaViewerMessageContext? { - val message = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return null - if (message.clientConversationId != messageContext.conversationId) return null - if (!isSnapContentType(message.contentType)) return null + val modCtx = this@MediaDownloader.context + val message = modCtx.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return null + if (message.clientConversationId != messageContext.conversationId || !isSnapContentType(message.contentType)) return null return messageContext } private fun resolveLegacyViewerMessageContext(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? { - val parts = paramMap?.get("MESSAGE_ID") - ?.toString() - ?.split(':') - ?.takeIf { it.size == 3 } - ?: return null - - return OperaViewerMessageContext( - conversationId = parts[0], - clientMessageId = parts[2].toLongOrNull() ?: return null - ) + val parts = paramMap?.get("MESSAGE_ID")?.toString()?.split(':')?.takeIf { it.size == 3 } ?: return null + return OperaViewerMessageContext(conversationId = parts[0], clientMessageId = parts[2].toLongOrNull() ?: return null) } private fun parseViewerMessageContext(rawValue: String): OperaViewerMessageContext? { val parts = rawValue.split(':') if (parts.size < 3) return null - - val conversationId = parts.firstOrNull()?.takeIf { - runCatching { UUID.fromString(it) }.isSuccess - } ?: return null + val conversationId = parts.firstOrNull()?.takeIf { runCatching { UUID.fromString(it) }.isSuccess } ?: return null val clientMessageId = parts.lastOrNull()?.toLongOrNull() ?: return null - - return OperaViewerMessageContext( - conversationId = conversationId, - clientMessageId = clientMessageId - ) + return OperaViewerMessageContext(conversationId = conversationId, clientMessageId = clientMessageId) } fun resolveViewerMessageContextFromParamMap(paramMap: ParamMap? = lastSeenMapParams): OperaViewerMessageContext? { if (paramMap == null) return null if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext(paramMap) - - paramMap["MESSAGE_ID"]?.toString() - ?.let(::parseViewerMessageContext) - ?.let(::validateViewerMessageContext) - ?.let { return it } - - return paramMap.concurrentHashMap.values - .asSequence() - .mapNotNull { value -> - value?.toString()?.let(::parseViewerMessageContext) - } - .mapNotNull(::validateViewerMessageContext) - .firstOrNull() + paramMap["MESSAGE_ID"]?.toString()?.let(::parseViewerMessageContext)?.let(::validateViewerMessageContext)?.let { return it } + return paramMap.concurrentHashMap.values.asSequence().mapNotNull { it?.toString()?.let(::parseViewerMessageContext) }.mapNotNull(::validateViewerMessageContext).firstOrNull() } fun resolveCurrentSnapMessageContext(): OperaViewerMessageContext? { + val modCtx = this@MediaDownloader.context if (!useModernOperaViewerContext) return resolveLegacyViewerMessageContext() - - val messaging = context.feature(Messaging::class) + val messaging = modCtx.feature(Messaging::class) val currentConversationId = messaging.openedConversationUUID?.toString() val currentMessageId = messaging.lastFocusedMessageId.takeIf { it > 0L } - if (currentConversationId != null && currentMessageId != null) { - validateViewerMessageContext( - OperaViewerMessageContext( - conversationId = currentConversationId, - clientMessageId = currentMessageId - ) - )?.let { return it } + validateViewerMessageContext(OperaViewerMessageContext(conversationId = currentConversationId, clientMessageId = currentMessageId))?.let { return it } } - return resolveViewerMessageContextFromParamMap() } - private fun handleLocalReferences(path: String) = runBlocking { - Uri.parse(path).let { uri -> - if (uri.scheme == "file" || uri.scheme == null) { - return@let suspendCoroutine { continuation -> - context.httpServer.ensureServerStarted()?.let { server -> - val file = Paths.get(uri.path).toFile() - val url = server.putDownloadableContent(file.inputStream(), file.length()) - continuation.resumeWith(Result.success(url)) - } ?: run { - continuation.resumeWith(Result.failure(Exception("Failed to start http server"))) - } - } - } - path + private suspend fun handleLocalReferences(path: String): String { + val modCtx = this@MediaDownloader.context + val uri = Uri.parse(path) + if (uri.scheme == "http" || uri.scheme == "https") return path + return suspendCoroutine { continuation -> + modCtx.httpServer.ensureServerStarted()?.let { server -> + runCatching { + val file = java.io.File(uri.path ?: path) + if (!file.exists()) { continuation.resume(path); return@runCatching } + val url = server.putDownloadableContent(file.inputStream(), file.length()) + continuation.resume(url) + }.onFailure { continuation.resume(path) } + } ?: continuation.resume(path) } } - private fun downloadOperaMedia( - downloadManagerClient: DownloadManagerClient, - mediaInfoMap: Map, - paramMap: ParamMap - ) { + private suspend fun downloadOperaMedia(downloadManagerClient: DownloadManagerClient, mediaInfoMap: Map, paramMap: ParamMap) { + val modCtx = this@MediaDownloader.context if (mediaInfoMap.isEmpty()) return - - // Story Snap Entry (images) paramMap["SNAP_ID"]?.toString()?.let { snapId -> - context.database.getStorySnapEntry(snapId)?.let { storySnapEntry -> - + modCtx.database.getStorySnapEntry(snapId)?.let { storySnapEntry -> downloadManagerClient.downloadSingleMedia( storySnapEntry.mediaUrl ?: throw Exception("Media URL not found"), DownloadMediaType.fromUri(Uri.parse(storySnapEntry.mediaUrl)), - (storySnapEntry.mediaKey to storySnapEntry.mediaIv) - .takeIf { it.first != null && it.second != null } - ?.let { (key, iv) -> MediaEncryptionKeyPair(key!!, iv!!, urlSafe = false) } - ) - return + (storySnapEntry.mediaKey to storySnapEntry.mediaIv).takeIf { it.first != null && it.second != null }?.let { (k, i) -> MediaEncryptionKeyPair(k!!, i!!, urlSafe = false) } + ); return } } - - val originalMediaInfo = mediaInfoMap[SplitMediaAssetType.ORIGINAL]!! - val originalMediaInfoReference = handleLocalReferences(originalMediaInfo.uri) - - // Overlay (if present) + val originalMediaRef = handleLocalReferences(mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.uri) mediaInfoMap[SplitMediaAssetType.OVERLAY]?.let { overlay -> - val overlayReference = handleLocalReferences(overlay.uri) - + val overlayRef = handleLocalReferences(overlay.uri) downloadManagerClient.downloadMediaWithOverlay( - original = InputMedia( - originalMediaInfoReference, - DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - originalMediaInfo.encryption?.toKeyPair() - ), - overlay = InputMedia( - overlayReference, - DownloadMediaType.fromUri(Uri.parse(overlayReference)), - overlay.encryption?.toKeyPair(), - isOverlay = true - ) - ) - return + InputMedia(originalMediaRef, DownloadMediaType.fromUri(Uri.parse(originalMediaRef)), mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.encryption?.toKeyPair()), + InputMedia(overlayRef, DownloadMediaType.fromUri(Uri.parse(overlayRef)), overlay.encryption?.toKeyPair(), isOverlay = true) + ); return } - - // Single media (video/DASH) - downloadManagerClient.downloadSingleMedia( - originalMediaInfoReference, - DownloadMediaType.fromUri(Uri.parse(originalMediaInfoReference)), - originalMediaInfo.encryption?.toKeyPair() - ) + downloadManagerClient.downloadSingleMedia(originalMediaRef, DownloadMediaType.fromUri(Uri.parse(originalMediaRef)), mediaInfoMap[SplitMediaAssetType.ORIGINAL]!!.encryption?.toKeyPair()) } fun canAutoDownloadMessage(databaseMessage: ConversationMessage): Boolean { - if (context.config.downloader.preventSelfAutoDownload.get() && databaseMessage.senderId == context.database.myUserId) return false + val modCtx = this@MediaDownloader.context + if (modCtx.config.downloader.preventSelfAutoDownload.get() && databaseMessage.senderId == modCtx.database.myUserId) return false return canUseRule(databaseMessage.clientConversationId!!) } - /** - * Handles the media from the opera viewer - * - * @param paramMap the parameters from the opera viewer - * @param mediaInfoMap the media info map - * @param forceDownload if the media should be downloaded - */ - private fun handleOperaMedia( - paramMap: ParamMap, - mediaInfoMap: Map, - forceDownload: Boolean, - forceAllowDuplicate: Boolean = false - ) { - - // ─── Messages ───────────────────────── - resolveViewerMessageContextFromParamMap(paramMap)?.takeIf { - forceDownload || shouldAutoDownload("friend_snaps") - }?.let { messageContext -> - val conversationMessage = context.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return@let - val conversationId = conversationMessage.clientConversationId!! - - if (!forceDownload && !canUseRule(conversationId)) return@let - - val senderId = conversationMessage.senderId!! - if (!forceDownload && context.config.downloader.preventSelfAutoDownload.get() && - senderId == context.database.myUserId - ) return@let - - val author = context.database.getFriendInfo(senderId) ?: return@let - val authorUsername = author.usernameForSorting!! - val mediaId = paramMap["MEDIA_ID"]?.toString()?.substringAfter("-")?.substringBefore(".") ?: "" - - downloadOperaMedia( - provideDownloadManagerClient( - mediaIdentifier = "$conversationId$senderId${conversationMessage.serverMessageId}$mediaId", - mediaAuthor = authorUsername, - creationTimestamp = conversationMessage.creationTimestamp, - downloadSource = MediaDownloadSource.CHAT_MEDIA, - friendInfo = author, - forceAllowDuplicate = forceAllowDuplicate - ), - mediaInfoMap, - paramMap - ) + private suspend fun handleOperaMedia(paramMap: ParamMap, mediaInfoMap: Map, forceDownload: Boolean, forceAllowDuplicate: Boolean = false, isBatch: Boolean = false) { + val modCtx = this@MediaDownloader.context + resolveViewerMessageContextFromParamMap(paramMap)?.takeIf { forceDownload || shouldAutoDownload("friend_snaps") }?.let { messageContext -> + val msg = modCtx.database.getConversationMessageFromId(messageContext.clientMessageId) ?: return@let + if (!forceDownload && (!canUseRule(msg.clientConversationId!!) || (modCtx.config.downloader.preventSelfAutoDownload.get() && msg.senderId == modCtx.database.myUserId))) return@let + val author = modCtx.database.getFriendInfo(msg.senderId!!) ?: return@let + downloadOperaMedia(provideDownloadManagerClient("${msg.clientConversationId}${msg.senderId}${msg.serverMessageId}", author.usernameForSorting!!, msg.creationTimestamp, MediaDownloadSource.CHAT_MEDIA, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) return } - - // ─── Private Friend Story ───────────────────────── - paramMap["PLAYLIST_V2_GROUP"]?.takeIf { - forceDownload || shouldAutoDownload("friend_stories") - }?.let { playlistGroup -> - val playlistGroupString = playlistGroup.toString() - - val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.let { - if (it.contains("userId=")) it.substringAfter("userId=").substringBefore(",") else null - } ?: if (playlistGroupString.contains("storyUserId=")) { - playlistGroupString.substringAfter("storyUserId=").substringBefore(",") - } else { - //story replies - val arroyoMessageId = playlistGroup::class.java.methods.firstOrNull { it.name == "getId" } - ?.invoke(playlistGroup)?.toString() - ?.split(":")?.getOrNull(2) ?: return@let - - val conversationMessage = context.database.getConversationMessageFromId(arroyoMessageId.toLong()) ?: return@let - val conversationParticipants = context.database.getConversationParticipants(conversationMessage.clientConversationId.toString()) ?: return@let - conversationParticipants.firstOrNull { it != conversationMessage.senderId } - } - - val author = context.database.getFriendInfo( - if (storyUserId == null || storyUserId == "null") - context.database.myUserId - else storyUserId - ) ?: throw Exception("Friend not found in database") - val authorName = author.usernameForSorting!! - - if (!forceDownload) { - if (context.config.downloader.preventSelfAutoDownload.get() && author.userId == context.database.myUserId) return - if (!canUseRule(author.userId!!)) return - } - - downloadOperaMedia( - provideDownloadManagerClient( - mediaIdentifier = paramMap["MEDIA_ID"].toString(), - mediaAuthor = authorName, - creationTimestamp = paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("timestamp=") - ?.substringBefore(",")?.toLongOrNull(), - downloadSource = MediaDownloadSource.STORY, - friendInfo = author, - forceAllowDuplicate = forceAllowDuplicate - ), - mediaInfoMap, - paramMap - ) + paramMap["PLAYLIST_V2_GROUP"]?.takeIf { forceDownload || shouldAutoDownload("friend_stories") }?.let { + val storyUserId = paramMap["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() ?: paramMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("userId=")?.substringBefore(",") + val author = modCtx.database.getFriendInfo(storyUserId ?: modCtx.database.myUserId) ?: return@let + if (!forceDownload && ((modCtx.config.downloader.preventSelfAutoDownload.get() && author.userId == modCtx.database.myUserId) || !canUseRule(author.userId!!))) return@let + downloadOperaMedia(provideDownloadManagerClient(paramMap["MEDIA_ID"].toString(), author.usernameForSorting!!, null, MediaDownloadSource.STORY, author, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) return } - - // ─── Public Stories / Spotlight ─────────────────── val snapSource = paramMap["SNAP_SOURCE"].toString() - - //spotlight if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || shouldAutoDownload("spotlight"))) { - downloadOperaMedia(provideDownloadManagerClient( - mediaIdentifier = paramMap["SNAP_ID"].toString(), - downloadSource = MediaDownloadSource.SPOTLIGHT, - mediaAuthor = paramMap["CREATOR_DISPLAY_NAME"].toString(), - creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), - forceAllowDuplicate = forceAllowDuplicate, - ), mediaInfoMap, paramMap) - return + downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), paramMap["CREATOR_DISPLAY_NAME"].toString(), null, MediaDownloadSource.SPOTLIGHT, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap); return } - - //stories with mpeg dash media - if (paramMap.containsKey("LONGFORM_VIDEO_PLAYLIST_ITEM") && forceDownload) { - val storyName = paramMap["STORY_NAME"].toString().sanitizeForPath() - //get the position of the media in the playlist and the duration - val snapItem = SnapPlaylistItem(paramMap["SNAP_PLAYLIST_ITEM"]!!) - val snapChapterList = LongformVideoPlaylistItem(paramMap["LONGFORM_VIDEO_PLAYLIST_ITEM"]!!).chapters - val currentChapterIndex = snapChapterList.indexOfFirst { it.snapId == snapItem.snapId } - - if (snapChapterList.isEmpty()) { - context.shortToast(translations["dash_no_chapter"]) - return - } - - fun prettyPrintTime(time: Long): String { - val seconds = time / 1000 - val minutes = seconds / 60 - val hours = minutes / 60 - return "${(hours % 24).toString().padStart(2, '0')}:${(minutes % 60).toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}" - } - - val playlistUrl = paramMap["MEDIA_ID"].toString().let { - val urlIndexes = arrayOf(it.indexOf("https://cf-st.sc-cdn.net"), it.indexOf("https://bolt-gcdn.sc-cdn.net")) - - urlIndexes.firstOrNull { index -> index != -1 }?.let { validIndex -> - it.substring(validIndex) - } ?: "${RemoteMediaResolver.CF_ST_CDN_D}$it" - } - - context.runOnUiThread { - val tr = context.translation.getCategory("download_processor.dash_dialog") - val chapters = snapChapterList.mapIndexed { index, snapChapter -> - val nextChapter = snapChapterList.getOrNull(index + 1) - val duration = nextChapter?.startTimeMs?.minus(snapChapter.startTimeMs) - SnapChapterInfo(snapChapter.startTimeMs, duration) - } - val cancelStr = context.translation["button.cancel"] - val downloadStr = context.translation["button.download"] - - createComposeAlertDialog(context.mainActivity!!) { alertDialog -> - PurrfectOverlayTheme { - val selected = remember { mutableStateListOf().apply { add(currentChapterIndex) } } - PurrfectGlassCard( - title = tr["title"], - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp, max = 320.dp) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - itemsIndexed(chapters) { index, item -> - val label = tr.format("snap_text", "from" to prettyPrintTime(item.offset), "to" to prettyPrintTime(item.offset + (item.duration ?: 0))) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 10.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Checkbox( - checked = selected.contains(index), - onCheckedChange = { checked -> - if (checked) selected.add(index) else selected.remove(index) - }, - colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) - ) - Text( - label, - style = MaterialTheme.typography.bodyMedium, - color = PurrfectOverlayPalette.textPrimary - ) - } - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = selected.size == chapters.size, - onCheckedChange = { checked -> - if (checked) { - selected.clear() - selected.addAll(0 until chapters.size) - } else { - selected.clear() - } - }, - colors = CheckboxDefaults.colors(checkedColor = PurrfectOverlayPalette.glowPrimary) - ) - Text( - tr["download_all"] ?: "Select All", - style = MaterialTheme.typography.bodyMedium, - color = PurrfectOverlayPalette.textPrimary - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - onClick = { alertDialog.dismiss() }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = PurrfectOverlayPalette.textPrimary) - ) { - Text(cancelStr) - } - Button( - onClick = { - val groups = mutableListOf>() - var lastIdx = -1 - chapters.forEachIndexed { index, info -> - if (selected.contains(index)) { - if (lastIdx == -1 || index != lastIdx + 1) groups.add(mutableListOf()) - groups.last().add(info) - lastIdx = index - } - } - groups.forEach { group -> - val first = group.first() - val last = group.last() - val duration = if (first == last) first.duration else last.duration?.let { last.offset - first.offset + it } - provideDownloadManagerClient("${paramMap["STORY_ID"]}-${first.offset}", storyName, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate) - .downloadDashMedia(playlistUrl, first.offset.plus(100), duration) - } - alertDialog.dismiss() - }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(14.dp), - colors = ButtonDefaults.buttonColors(containerColor = PurrfectOverlayPalette.glowPrimary) - ) { - Text(downloadStr) - } - } - } - } - } - }.show() - } - } - if (!forceDownload && !shouldAutoDownload("public_stories")) return - - //public stories - val author = ( - paramMap["USER_ID"]?.let { context.database.getFriendInfo(it.toString())?.mutableUsername } // only for following users - ?: paramMap["USERNAME"]?.toString()?.takeIf { - it.contains("value=") - }?.substringAfter("value=")?.substringBefore(")")?.substringBefore(",") - ?: paramMap["CONTEXT_USER_IDENTITY"]?.toString()?.takeIf { - it.contains("username=") - }?.substringAfter("username=")?.substringBefore(",") - // fallback display name - ?: paramMap["USER_DISPLAY_NAME"]?.toString()?.takeIf { it.isNotEmpty() } - ?: paramMap["TIME_STAMP"]?.toString() - ?: "unknown" - ).sanitizeForPath() - - downloadOperaMedia(provideDownloadManagerClient( - mediaIdentifier = paramMap["SNAP_ID"].toString(), - mediaAuthor = author, - downloadSource = MediaDownloadSource.PUBLIC_STORY, - creationTimestamp = paramMap["SNAP_TIMESTAMP"]?.toString()?.toLongOrNull(), - forceAllowDuplicate = forceAllowDuplicate, - ), mediaInfoMap, paramMap) + val author = (paramMap["USER_ID"]?.let { modCtx.database.getFriendInfo(it.toString())?.mutableUsername } ?: paramMap["USERNAME"]?.toString()?.substringAfter("value=")?.substringBefore(")") ?: "unknown").sanitizeForPath() + downloadOperaMedia(provideDownloadManagerClient(paramMap["SNAP_ID"].toString(), author, null, MediaDownloadSource.PUBLIC_STORY, null, forceAllowDuplicate, isBatch), mediaInfoMap, paramMap) } - private fun shouldAutoDownload(keyFilter: String? = null): Boolean { - val options by context.config.downloader.autoDownloadSources - return options.any { keyFilter == null || it.contains(keyFilter, true) } - } + private fun shouldAutoDownload(keyFilter: String? = null): Boolean = this@MediaDownloader.context.config.downloader.autoDownloadSources.get().any { keyFilter == null || it.contains(keyFilter, true) } override fun init() { + val modCtx = this@MediaDownloader.context if (getRuleState() == null) return onNextActivityCreate { - context.mappings.useMapper(OperaPageViewControllerMapper::class) { + modCtx.mappings.useMapper(OperaPageViewControllerMapper::class) { arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> - classReference.get()?.hook( - methodName.get() ?: return@forEach, - HookStage.AFTER - ) onOperaViewStateCallback@{ param -> + classReference.get()?.hook(methodName.get() ?: return@forEach, HookStage.AFTER) { param -> val viewState = (param.thisObject() as Any).getObjectField(viewStateField.get()!!).toString() - - if (viewState != "FULLY_DISPLAYED") { - return@onOperaViewStateCallback - } - + if (viewState != "FULLY_DISPLAYED" && viewState != "DISPLAYED") return@hook val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> - val layerParamMaps = operaLayerList - .asSequence() - .mapNotNull { layerObj -> - layerObj?.let { runCatching { Layer(it).paramMap }.getOrNull() } - } - .toList() - val firstLayerParamMap = layerParamMaps.firstOrNull() - val mediaParamMap: ParamMap = if (useModernOperaViewerContext) { - ( - // Chat snaps need the primary MESSAGE_ID-bearing param map for mark-as-seen to work. - layerParamMaps.firstOrNull { - it.containsKey("MESSAGE_ID") && - (it.containsKey("image_media_info") || it.containsKey("video_media_info_list")) - } - ?: firstLayerParamMap?.takeIf { - it.containsKey("image_media_info") || it.containsKey("video_media_info_list") - } - ?: layerParamMaps.firstOrNull { - it.containsKey("image_media_info") || it.containsKey("video_media_info_list") - } - ) - } else { - layerParamMaps.firstOrNull { - it.containsKey("image_media_info") || it.containsKey("video_media_info_list") - } - } ?: return@onOperaViewStateCallback - + val layerParamMaps = operaLayerList.mapNotNull { l -> l?.let { runCatching { Layer(it).paramMap }.getOrNull() } } + val mediaParamMap = if (useModernOperaViewerContext) { + layerParamMaps.firstOrNull { it.containsKey("MESSAGE_ID") && (it.containsKey("image_media_info") || it.containsKey("video_media_info_list")) } + ?: layerParamMaps.firstOrNull { it.containsKey("image_media_info") || it.containsKey("video_media_info_list") } + } else layerParamMaps.firstOrNull { it.containsKey("image_media_info") || it.containsKey("video_media_info_list") } ?: return@hook val mediaInfoMap = mutableMapOf() - val isVideo = mediaParamMap.containsKey("video_media_info_list") - - mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo( - (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! - ) - - if (context.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) { - mediaInfoMap[SplitMediaAssetType.OVERLAY] = - MediaInfo(mediaParamMap["overlay_image_media_info"]!!) - } - - val shouldAutoDownload = shouldAutoDownload() - - if (shouldAutoDownload && lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri == mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri) return@onOperaViewStateCallback - - lastSeenMapParams = mediaParamMap - lastSeenMediaInfoMap = mediaInfoMap - - if (!shouldAutoDownload) { - return@onOperaViewStateCallback - } - - context.executeAsync { - runCatching { - handleOperaMedia(mediaParamMap, mediaInfoMap, false) - }.onFailure { - context.log.error("Failed to handle opera media", it) - context.longToast(it.message) - } - } + val isVideo = mediaParamMap!!.containsKey("video_media_info_list") + mediaInfoMap[SplitMediaAssetType.ORIGINAL] = MediaInfo(mediaParamMap[if (isVideo) "video_media_info_list" else "image_media_info"]!!) + if (modCtx.config.downloader.mergeOverlays.get() && mediaParamMap.containsKey("overlay_image_media_info")) mediaInfoMap[SplitMediaAssetType.OVERLAY] = MediaInfo(mediaParamMap["overlay_image_media_info"]!!) + if (shouldAutoDownload() && lastSeenMediaInfoMap?.get(SplitMediaAssetType.ORIGINAL)?.uri == mediaInfoMap[SplitMediaAssetType.ORIGINAL]?.uri) return@hook + lastSeenMapParams = mediaParamMap; lastSeenMediaInfoMap = mediaInfoMap + if (pendingBatchDownloadIndices != null) { modCtx.coroutineScope.launch { processNextBatchDownload(mediaParamMap, mediaInfoMap) }; return@hook } + if (!shouldAutoDownload()) return@hook + modCtx.coroutineScope.launch { runCatching { handleOperaMedia(mediaParamMap, mediaInfoMap, false) } } } } } } } - private fun downloadMessageAttachments( - friendInfo: FriendInfo, - message: ConversationMessage, - authorName: String, - attachments: List, - forceAllowDuplicate: Boolean = false - ) { - attachments.forEach { attachment -> - runCatching { - provideDownloadManagerClient( - mediaIdentifier = "${message.clientConversationId}${message.senderId}${message.serverMessageId}${attachment.mediaUniqueId}", - downloadSource = MediaDownloadSource.CHAT_MEDIA, - mediaAuthor = authorName, - friendInfo = friendInfo, - forceAllowDuplicate = forceAllowDuplicate, - creationTimestamp = message.creationTimestamp, - ).apply { - downloadInputMedias( - arrayOf(attachment.createInputMedia()!!) - ) - } - }.onFailure { - context.longToast(translations["failed_generic_toast"]) - context.log.error("Failed to download", it) - } - } - } - - private fun DecodedAttachment.getInfo(): String { - return "${translations["attachment_type.${type.key}"]} ${attachmentInfo?.resolution?.let { "(${it.first}x${it.second})" } ?: ""}" - } - - @SuppressLint("SetTextI18n") - private fun previewAttachment( - attachment: DecodedAttachment - ) { - var previewBitmap: Bitmap? = null - val previewCoroutine = context.coroutineScope.launch { - runCatching { - attachment.openStream { attachmentStream, _ -> - val downloadedMediaList = mutableMapOf() - - MediaDownloaderHelper.getSplitElements(attachmentStream!!) { - type, inputStream -> - downloadedMediaList[type] = inputStream.readBytes() - } - - val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@openStream - val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] - - var bitmap = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) - ?: throw Exception("preview is null") - - overlay?.also { - bitmap = PreviewUtils.mergeBitmapOverlay(bitmap, BitmapFactory.decodeByteArray(it, 0, it.size)) - } - - previewBitmap = bitmap - } - }.onFailure { - context.shortToast(translations["failed_to_create_preview_toast"]) - context.log.error("Failed to create preview", it) - } - } - - with(ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity)) { - val viewGroup = LinearLayout(context).apply { - layoutParams = MarginLayoutParams( - MarginLayoutParams.MATCH_PARENT, - MarginLayoutParams.MATCH_PARENT - ) - gravity = Gravity.CENTER_HORIZONTAL or Gravity.CENTER_VERTICAL - addView(ProgressBar(context).apply { - isIndeterminate = true - }) - } - - setOnDismissListener { - previewCoroutine.cancel() - } - - previewCoroutine.invokeOnCompletion { cause -> - if (previewCoroutine.isCancelled) return@invokeOnCompletion - runOnUiThread { - viewGroup.removeAllViews() - if (cause != null) { - viewGroup.addView(TextView(context).apply { - text = - translations["failed_to_create_preview_toast"] + "\n" + cause.message - setPadding(30, 30, 30, 30) - }) - return@runOnUiThread - } - - viewGroup.addView(ImageView(context).apply { - setImageBitmap(previewBitmap) - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT - ) - adjustViewBounds = true - }) - } - } - - runOnUiThread { - show().apply { - setContentView(viewGroup) - window?.setLayout( - context.resources.displayMetrics.widthPixels, - context.resources.displayMetrics.heightPixels - ) - } - } - } - } - - @SuppressLint("SetTextI18n") - fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false, forceDownloadFirst: Boolean = false) { - val messageLogger = context.feature(MessageLogger::class) - val message = context.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found in database") - - val friendInfo = context.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found in database") - val authorName = friendInfo.usernameForSorting!! - - val decodedAttachments = ( - messageLogger.takeIf { it.isEnabled }?.getMessageObject(message.clientConversationId!!, message.clientMessageId.toLong())?.let { - MessageDecoder.decode(it.getAsJsonObject("mMessageContent")) - } ?: MessageDecoder.decode( - protoReader = ProtoReader(message.messageContent!!) - ).toMutableList().apply { - val quotedMessage = message.quotedServerMessageId?.takeIf { it > 0 }?.let { quotedMessageId -> - context.database.getConversationServerMessage(message.clientConversationId!!, quotedMessageId) - } ?: return@apply - addAll(0, MessageDecoder.decode( - protoReader = ProtoReader(quotedMessage.messageContent ?: return@apply) - )) - } - ).toMutableList() - - context.feature(Messaging::class).conversationManager?.takeIf { - decodedAttachments.isEmpty() - }?.also { conversationManager -> - runBlocking { - suspendCoroutine { continuation -> - conversationManager.fetchMessage(message.clientConversationId!!, message.clientMessageId.toLong(), onSuccess = { message -> - decodedAttachments.addAll(MessageDecoder.decode(message.messageContent!!)) - continuation.resumeWith(Result.success(Unit)) - }, onError = { - continuation.resumeWith(Result.success(Unit)) - }) - } - } - } - - if (decodedAttachments.isEmpty()) { - context.shortToast(translations["no_attachments_toast"]) - return - } - + suspend fun downloadMessageId(messageId: Long, forceAllowDuplicate: Boolean = false, isPreview: Boolean = false, forceDownloadFirst: Boolean = false) { + val modCtx = this@MediaDownloader.context + val message = modCtx.database.getConversationMessageFromId(messageId) ?: throw Exception("Message not found") + val friendInfo = modCtx.database.getFriendInfo(message.senderId!!) ?: throw Exception("Friend not found") + val decodedAttachments = MessageDecoder.decode(ProtoReader(message.messageContent!!)).toMutableList() + if (decodedAttachments.isEmpty()) { modCtx.shortToast(translations["no_attachments_toast"] ?: "No Attachments"); return } + if (!isPreview) { - if (forceDownloadFirst || - decodedAttachments.size == 1 || - context.isMainActivityPaused - ) { - downloadMessageAttachments(friendInfo, message, authorName, - listOf(decodedAttachments.first()), - forceAllowDuplicate = forceAllowDuplicate - ) - return - } - - runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { - val selectedAttachments = mutableListOf().apply { - addAll(decodedAttachments.indices) - } - setMultiChoiceItems( - decodedAttachments.mapIndexed { index, decodedAttachment -> - "${index + 1}: ${decodedAttachment.getInfo()}" - }.toTypedArray(), - decodedAttachments.map { true }.toBooleanArray() - ) { _, which, isChecked -> - if (isChecked) { - selectedAttachments.add(which) - } else if (selectedAttachments.contains(which)) { - selectedAttachments.remove(which) - } - } - setTitle(translations["select_attachments_title"]) - setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } - setPositiveButton(this@MediaDownloader.context.translation["button.download"]) { _, _ -> - downloadMessageAttachments(friendInfo, message, authorName, selectedAttachments.map { decodedAttachments[it] }, - forceAllowDuplicate = forceAllowDuplicate - ) - } - }.show() + if (forceDownloadFirst || decodedAttachments.size == 1 || modCtx.isMainActivityPaused) { + downloadMessageAttachments(friendInfo, message, friendInfo.usernameForSorting!!, listOf(decodedAttachments.first()), forceAllowDuplicate) + } else { + withContext(Dispatchers.Main) { showAttachmentSelectionDialog(friendInfo, message, decodedAttachments, forceAllowDuplicate) } } return } - - if (decodedAttachments.size == 1) { - previewAttachment(decodedAttachments.first()) - return - } - - runOnUiThread { - ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity).apply { - var selectedAttachment = 0 - setSingleChoiceItems( - decodedAttachments.mapIndexed { index, decodedAttachment -> "${index + 1}: ${decodedAttachment.getInfo()}" }.toTypedArray(), - 0 - ) { _, which -> - selectedAttachment = which - } - setTitle(translations["select_attachments_title"]) - setNegativeButton(this@MediaDownloader.context.translation["button.cancel"]) { dialog, _ -> dialog.dismiss() } - setPositiveButton(this@MediaDownloader.context.translation["chat_action_menu.preview_button"]) { _, _ -> - previewAttachment(decodedAttachments[selectedAttachment]) - } + + if (decodedAttachments.size == 1) { previewAttachment(decodedAttachments.first()); return } + + withContext(Dispatchers.Main) { + val mainActivity = modCtx.mainActivity ?: return@withContext + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + var selected = 0 + setSingleChoiceItems(decodedAttachments.mapIndexed { i, a -> "${i + 1}: ${translations["attachment_type.${a.type.key}"] ?: a.type.key}" }.toTypedArray(), 0) { _, w -> selected = w } + setPositiveButton(modCtx.translation["chat_action_menu.preview_button"] ?: "Preview") { _, _ -> previewAttachment(decodedAttachments[selected]) } }.show() } } - fun downloadProfilePicture(url: String, author: String) { - provideDownloadManagerClient( - mediaIdentifier = url.hashCode().toString(16).replaceFirst("-", ""), - mediaAuthor = author, - downloadSource = MediaDownloadSource.PROFILE_PICTURE - ).downloadSingleMedia( - url, - DownloadMediaType.REMOTE_MEDIA - ) - } - - /** - * Called when a message is focused in chat - */ - fun onMessageActionMenu(isPreviewMode: Boolean, forceAllowDuplicate: Boolean = false) { - val messaging = context.feature(Messaging::class) - if (messaging.openedConversationUUID == null) return - - context.executeAsync { - downloadMessageId(messaging.lastFocusedMessageId, forceAllowDuplicate, isPreviewMode) + private fun downloadMessageAttachments(f: FriendInfo, m: ConversationMessage, author: String, attachments: List, forceDup: Boolean) { + attachments.forEach { a -> + runCatching { provideDownloadManagerClient("${m.clientConversationId}${m.senderId}${m.serverMessageId}", author, m.creationTimestamp, MediaDownloadSource.CHAT_MEDIA, f, forceDup).downloadInputMedias(arrayOf(a.createInputMedia()!!)) } } } + + private fun showAttachmentSelectionDialog(friendInfo: FriendInfo, message: ConversationMessage, attachments: List, forceAllowDuplicate: Boolean) { + val modCtx = this@MediaDownloader.context + val mainActivity = modCtx.mainActivity ?: return + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + val selected = mutableListOf().apply { addAll(attachments.indices) } + setMultiChoiceItems(attachments.mapIndexed { i, a -> "${i + 1}: ${translations["attachment_type.${a.type.key}"] ?: a.type.key}" }.toTypedArray(), attachments.map { true }.toBooleanArray()) { _, which, isChecked -> if (isChecked) selected.add(which) else selected.remove(which) } + setPositiveButton(modCtx.translation["button.download"] ?: "Download") { _, _ -> downloadMessageAttachments(friendInfo, message, friendInfo.usernameForSorting!!, selected.map { attachments[it] }, forceAllowDuplicate) } + }.show() + } + + @SuppressLint("SetTextI18n") + private fun previewAttachment(attachment: DecodedAttachment) { + val modCtx = this@MediaDownloader.context + var previewBitmap: Bitmap? = null + val previewCoroutine = modCtx.coroutineScope.launch { + runCatching { + attachment.openStream { attachmentStream, _ -> + val downloadedMediaList = mutableMapOf() + MediaDownloaderHelper.getSplitElements(attachmentStream!!) { type, inputStream -> downloadedMediaList[type] = inputStream.readBytes() } + val originalMedia = downloadedMediaList[SplitMediaAssetType.ORIGINAL] ?: return@openStream + val overlay = downloadedMediaList[SplitMediaAssetType.OVERLAY] + var bitmap = PreviewUtils.createPreview(originalMedia, isVideo = FileType.fromByteArray(originalMedia).isVideo) ?: throw Exception("preview is null") + overlay?.also { bitmap = PreviewUtils.mergeBitmapOverlay(bitmap, BitmapFactory.decodeByteArray(it, 0, it.size)) } + previewBitmap = bitmap + } + } + } + modCtx.runOnUiThread { + val mainActivity = modCtx.mainActivity ?: return@runOnUiThread + val viewGroup = LinearLayout(modCtx.androidContext).apply { gravity = Gravity.CENTER; addView(ProgressBar(modCtx.androidContext).apply { isIndeterminate = true }) } + ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply { + setOnDismissListener { previewCoroutine.cancel() } + previewCoroutine.invokeOnCompletion { cause -> + modCtx.runOnUiThread { + viewGroup.removeAllViews() + if (cause != null) { viewGroup.addView(TextView(modCtx.androidContext).apply { text = "Failed to create preview"; setPadding(30, 30, 30, 30) }); return@runOnUiThread } + viewGroup.addView(ImageView(modCtx.androidContext).apply { setImageBitmap(previewBitmap); adjustViewBounds = true }) + } + } + val dialog = show() + dialog.setContentView(viewGroup) + dialog.window?.setLayout(modCtx.androidContext.resources.displayMetrics.widthPixels, modCtx.androidContext.resources.displayMetrics.heightPixels) + } + } + } + + fun downloadProfilePicture(url: String, author: String) { + provideDownloadManagerClient(url.hashCode().toString(16).replaceFirst("-", ""), author, null, MediaDownloadSource.PROFILE_PICTURE).downloadSingleMedia(url, DownloadMediaType.REMOTE_MEDIA) + } + + fun onMessageActionMenu(isPreviewMode: Boolean, forceAllowDuplicate: Boolean = false) { + val modCtx = this@MediaDownloader.context + val messaging = modCtx.feature(Messaging::class) + if (messaging.openedConversationUUID == null) return + modCtx.coroutineScope.launch { downloadMessageId(messaging.lastFocusedMessageId, forceAllowDuplicate, isPreviewMode) } + } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index d19f9f52..e2837409 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -9,7 +9,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build @@ -18,23 +17,22 @@ import androidx.core.content.edit import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import me.eternal.purrfectsnap.bridge.AutoOpenInterface -import me.eternal.purrfectsnap.common.BuildConfig +import me.eternal.purrfectsnap.common.config.PropertyValue import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.MessageState import me.eternal.purrfectsnap.common.data.MessageUpdate import me.eternal.purrfectsnap.common.data.MessagingRuleType import me.eternal.purrfectsnap.core.event.events.impl.BuildMessageEvent -import me.eternal.purrfectsnap.core.wrapper.impl.Message import me.eternal.purrfectsnap.core.features.MessagingRuleFeature import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging -import me.eternal.purrfectsnap.core.features.impl.tweaks.PerformanceMode 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 java.util.* -import java.util.Objects import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -42,400 +40,326 @@ import java.util.concurrent.atomic.AtomicLong import kotlin.coroutines.resume import kotlin.random.Random +/** + * AutoOpenSnaps: High-performance engine with real-time diagnostics. + * Optimized for 20+ snaps/s with accurate stats and background resilience. + */ 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" + const val ACTION_STOP_ENGINE = "me.eternal.purrfectsnap.AUTO_OPEN_SNAPS_STOP_ENGINE" + private const val STATUS_NOTIFICATION_ID = 54321 private const val NOTIFICATION_GROUP_KEY = "purrfectsnap.AUTO_OPEN" private const val PREF_TOTAL_OPENED = "auto_open_total_opened" private const val PREF_SESSION_START = "auto_open_session_start" - private const val PREF_SAVED_QUEUE = "auto_open_saved_queue" + + private const val LAZY_SAVE_INTERVAL_MS = 600_000L } private val gson = Gson() private val isPaused = AtomicBoolean(false) - private val totalProcessed = AtomicInteger(0) - private val sessionProcessed = AtomicInteger(0) + private val engineActive = AtomicBoolean(true) + private val totalProcessed = AtomicInteger(0) + private val sessionProcessed = AtomicInteger(0) private val sessionStartTime = AtomicLong(System.currentTimeMillis()) - private val totalPausedDuration = AtomicLong(0) - private var lastPausedAt = AtomicLong(0) private val averageProcessingTime = AtomicLong(800) - private val hasBeenActive = AtomicBoolean(false) - private val isScreenOn = AtomicBoolean(true) + private val lastSnapProcessedAt = AtomicLong(0) + + private val snapChannel = Channel(Channel.UNLIMITED) + private val openedSnapsIds = ConcurrentHashMap.newKeySet() + private val queuedSnaps = LinkedList() + private var engineJob: Job? = null + private val engineDispatcher = Dispatchers.Default.limitedParallelism(1) - private val snapQueue = MutableSharedFlow(extraBufferCapacity = 100) - private val openedSnaps = ConcurrentHashMap.newKeySet() - private val queuedSnaps = mutableListOf() - private val deadLetterQueue = mutableListOf() + private val autoOpenConfig by lazy { this@AutoOpenSnaps.context.config.messaging.autoOpenSnaps } + private val notificationManager by lazy { this@AutoOpenSnaps.context.androidContext.getSystemService(NotificationManager::class.java) } + private val prefs by lazy { this@AutoOpenSnaps.context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) } + private val messaging by lazy { this@AutoOpenSnaps.context.feature(Messaging::class) } + private var wakeLock: PowerManager.WakeLock? = null - private val metadataCache = Collections.synchronizedMap(object : LinkedHashMap() { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 500 - }) - - private val config by lazy { context.config.messaging.autoOpenSnaps } - private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java) } - private val prefs by lazy { context.androidContext.getSharedPreferences("me.eternal.purrfectsnap_preferences", Context.MODE_PRIVATE) } - - private var lastConversationId: String? = null private var currentStatusText = "Monitoring..." private var currentSpeedText = "Full Speed" - private var isCurrentlyWaiting = false - private var wakeLock: PowerManager.WakeLock? = null - private var wakeLockCooldownJob: Job? = null - private var lastQueueActivity = System.currentTimeMillis() - - private val lastNotificationUpdate = AtomicLong(0) + private var lastNotificationUpdate = 0L private val notificationUpdateDelay = 1000L private val pendingNotificationUpdate = AtomicBoolean(false) private val snapTimestamps = LinkedList() + private var lastConversationId: String? = null private val isSaving = AtomicBoolean(false) private val needsSaving = AtomicBoolean(false) private var isThermalThrottled = false private var lastThermalThrottleAt = 0L - private fun cancelStatusNotification() { - runCatching { notificationManager.cancel(STATUS_NOTIFICATION_ID) } - } - - data class SnapQueueItem( - val conversationId: String, - val messageId: Long, - val serverMessageId: Long?, - val senderId: String, - var senderName: String = "Pending...", - var conversationType: String = "Processing", - val contentType: String, - val timestamp: Long = System.currentTimeMillis() - ) - - private val autoOpenInterface = object : AutoOpenInterface.Stub() { - override fun getProcessedCount(): Int = sessionProcessed.get() - override fun getQueueItems(): List = synchronized(queuedSnaps) { queuedSnaps.map { gson.toJson(it) } } - override fun reset() { clearInternalState() } - } - - private fun clearInternalState() { - sessionProcessed.set(0) - totalProcessed.set(0) - totalPausedDuration.set(0) - lastPausedAt.set(0) - sessionStartTime.set(System.currentTimeMillis()) - synchronized(queuedSnaps) { queuedSnaps.clear() } - synchronized(deadLetterQueue) { deadLetterQueue.clear() } - openedSnaps.clear() - - prefs.edit() - .putLong(PREF_SESSION_START, System.currentTimeMillis()) - .remove(PREF_SAVED_QUEUE) - .remove(PREF_TOTAL_OPENED) - .apply() - - updateStatusNotification(force = true) - } - - fun getSnapMetadata(clientMessageId: Long): SnapQueueItem? = synchronized(queuedSnaps) { queuedSnaps.find { it.messageId == clientMessageId } } - - fun getInterface(): AutoOpenInterface = autoOpenInterface - - private val actionReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - when (intent?.action) { - ACTION_PAUSE_RESUME -> { - val paused = !isPaused.get() - isPaused.set(paused) - if (paused) lastPausedAt.set(System.currentTimeMillis()) - else { - if (lastPausedAt.get() > 0) totalPausedDuration.addAndGet(System.currentTimeMillis() - lastPausedAt.get()) - snapQueue.tryEmit(System.currentTimeMillis()) - } - updateStatusNotification(force = true) - } - ACTION_CLEAR_QUEUE -> clearInternalState() - Intent.ACTION_SCREEN_ON -> { isScreenOn.set(true); updateStatusNotification(force = true) } - Intent.ACTION_SCREEN_OFF -> isScreenOn.set(false) - } - } - } - - override fun init() { - val messaging = context.feature(Messaging::class) - restorePersistence() - hasBeenActive.set(config.globalState == true) - - if (config.allowRunningInBackground.get()) { - acquireWakeLock() - findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply { - hook("appStateChanged", HookStage.BEFORE) { param -> - if (config.allowRunningInBackground.get()) { - val state = param.arg(0).toString() - if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null) - } - } - hookConstructor(HookStage.AFTER) { param -> - methods.firstOrNull { it.name == "appStateChanged" }?.let { method -> - val enumClass = method.parameterTypes[0] - val activeState = enumClass.enumConstants?.firstOrNull { it.toString() == "ACTIVE" || it.toString() == "FOREGROUND" } - if (activeState != null) method.invoke(param.thisObject(), activeState) - } - } - } - findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply { - hook("onAppForegrounded", HookStage.BEFORE) { param -> if (config.allowRunningInBackground.get()) param.setResult(null) } - hook("onAppBackgrounded", HookStage.BEFORE) { param -> if (config.allowRunningInBackground.get()) param.setResult(null) } - } - } - - createNotificationChannels() - val filter = IntentFilter().apply { - addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(Intent.ACTION_BATTERY_CHANGED); addAction(Intent.ACTION_SCREEN_ON); addAction(Intent.ACTION_SCREEN_OFF) - } - - val batteryReceiver = object : BroadcastReceiver() { - override fun onReceive(ctx: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_BATTERY_CHANGED && config.thermalProtection.get()) { - val temp = intent.getIntExtra("temperature", 0) / 10f - if (temp >= 40f && !isThermalThrottled) { - isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis() - } else if (isThermalThrottled && temp <= 36f && System.currentTimeMillis() - lastThermalThrottleAt > 600000) { - isThermalThrottled = false - } - } - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - context.androidContext.registerReceiver(batteryReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.androidContext.registerReceiver(actionReceiver, filter) - context.androidContext.registerReceiver(batteryReceiver, filter) - } - - if (synchronized(queuedSnaps) { queuedSnaps.isNotEmpty() }) snapQueue.tryEmit(System.currentTimeMillis()) - - // Watchdog Loop - context.coroutineScope.launch(Dispatchers.Default) { - while (isActive) { - if (config.globalState != true) { shutdownFeature(); break } - val remainingCount = synchronized(queuedSnaps) { queuedSnaps.size } - - if (remainingCount > 0) { - lastQueueActivity = System.currentTimeMillis(); acquireWakeLock() - if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis()) - } else { - if (!isPaused.get() && System.currentTimeMillis() - lastQueueActivity > 300000) { - val revived = synchronized(deadLetterQueue) { if (deadLetterQueue.isNotEmpty()) deadLetterQueue.removeAt(0) else null } - if (revived != null) { synchronized(queuedSnaps) { queuedSnaps.add(revived) }; snapQueue.tryEmit(System.currentTimeMillis()) } - } - if (System.currentTimeMillis() - lastQueueActivity > 300000) { - startWakeLockCooldown() - } - } - updateStatusNotification() - delay(5000) - } - } - - // Processing Loop - context.coroutineScope.launch(Dispatchers.Default) { - snapQueue.collect { - if (isPaused.get() || config.globalState != true) return@collect - while (isActive && config.globalState == true) { - val item = synchronized(queuedSnaps) { if (queuedSnaps.isNotEmpty()) queuedSnaps.removeAt(0) else null } ?: break - - var resourceWaiting = true - while (resourceWaiting) { - if (config.globalState != true || isPaused.get()) break - val isWifi = isWifiConnected() - val isIdle = isDeviceIdle() - val onlyIdle = config.onlyWhenIdle.get() - val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false - - when { - config.onlyOnWifi.get() && !isWifi -> { - currentStatusText = "Waiting for WiFi..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000) - } - onlyIdle && !isIdle && !inSleepWindow -> { - currentStatusText = "Waiting for idle..."; currentSpeedText = "Throttled"; isCurrentlyWaiting = true; delay(5000) - } - else -> { - resourceWaiting = false; - val thermalActive = config.thermalProtection.get() && isThermalThrottled - currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed" - } - } - if (resourceWaiting) updateStatusNotification() - } - - if (isPaused.get() || config.globalState != true) { synchronized(queuedSnaps) { queuedSnaps.add(0, item) }; continue } - isCurrentlyWaiting = false - - // TIMING: 40ms switch - if (lastConversationId != null && lastConversationId != item.conversationId) { delay(40) } - lastConversationId = item.conversationId - currentStatusText = "Active"; updateStatusNotification() - - var success = false - val startTime = System.currentTimeMillis() - var currentRetryDelay = config.retryDelay.get().toLong() - - for (i in 0 until config.retryAttempts.get()) { - if (isPaused.get() || config.globalState != true) break - - // Bridge Handshake - if (messaging.conversationManager == null) { - runCatching { context.messagingBridge.triggerSessionStart() } - var waitTime = 0 - while (messaging.conversationManager == null && waitTime < 2000) { delay(100); waitTime += 100 } - } - - success = performOpen(messaging, item) - if (success) { - sessionProcessed.incrementAndGet() - totalProcessed.incrementAndGet() - synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 100) snapTimestamps.removeFirst() } - val duration = System.currentTimeMillis() - startTime - averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong()) - delay(5) - break - } - if (i < config.retryAttempts.get() - 1) { - currentStatusText = "Retrying..."; updateStatusNotification(); delay(currentRetryDelay); currentRetryDelay *= 2 - } - } - - if (!success && !isPaused.get()) { - currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification() - synchronized(openedSnaps) { openedSnaps.remove(item.messageId) } - synchronized(deadLetterQueue) { if (deadLetterQueue.size < 100) deadLetterQueue.add(item) else { deadLetterQueue.removeAt(0); deadLetterQueue.add(item) } } - } - - if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { - currentStatusText = "Monitoring..."; updateStatusNotification() - delay(50) - } - } - } - } - - // Global Detector - context.event.subscribe(BuildMessageEvent::class, priority = 103) { event -> - // GLOBAL SILENCE GUARD - if (config.globalState != true) return@subscribe - - val message = event.message - if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == context.database.myUserId) return@subscribe - - val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe - val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe - val serverMsgId = message.orderKey - val contentType = message.messageContent?.contentType - if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe - - if (config.globalState != true) return@subscribe - - // Whitelist Resilience: Robust rule check - val ruleState = context.config.rules.getRuleState(ruleType) - val isWhitelisted = getState(conversationId) - val canProcess = if (ruleState == me.eternal.purrfectsnap.common.data.RuleState.BLACKLIST) !isWhitelisted else isWhitelisted - - if (!canProcess) return@subscribe - - acquireWakeLock() - synchronized(openedSnaps) { - if (openedSnaps.contains(clientMessageId)) return@subscribe - openedSnaps.add(clientMessageId) - if (openedSnaps.size > 5000) openedSnaps.clear() - } - - val senderId = message.senderId?.toString() ?: "unknown" - val item = SnapQueueItem(conversationId, clientMessageId, serverMsgId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType)) - - synchronized(queuedSnaps) { - if (queuedSnaps.size >= config.queueSize.get()) queuedSnaps.removeFirstOrNull() - queuedSnaps.add(item) - } - - if (context.config.messaging.preFetchSnaps.get()) { - runCatching { messaging.conversationManager?.fetchMessage(conversationId, clientMessageId, {}, {}) } - } - - if (!isPaused.get()) snapQueue.tryEmit(System.currentTimeMillis()) - updateStatusNotification() - triggerLazySave() - } - } - - private suspend fun performOpen(messaging: Messaging, item: SnapQueueItem): Boolean = withContext(Dispatchers.IO) { - val manager = messaging.conversationManager ?: return@withContext false - suspendCancellableCoroutine { cont -> - runCatching { - manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> - if (result == null || result == "DUPLICATEREQUEST") { - cont.resume(true) - } else if (item.serverMessageId != null) { - manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> - cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") - } - } else { - cont.resume(false) - } - } - }.onFailure { cont.resume(false) } - } - } + private fun logInfo(msg: String) = this@AutoOpenSnaps.context.log.info("[AutoOpenEngine] $msg") + private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg", e) else this@AutoOpenSnaps.context.log.error("[AutoOpenEngine] $msg") private fun getSnapsPerSecond(): Double { val now = System.currentTimeMillis(); val window = 5000L synchronized(snapTimestamps) { - snapTimestamps.removeIf { now - it > window }; return (snapTimestamps.size.toDouble() / (window / 1000.0)) + snapTimestamps.removeIf { now - it > window } + // Smoother calculation for high-frequency bursts + return if (snapTimestamps.isEmpty()) 0.0 else (snapTimestamps.size.toDouble() / (window / 1000.0)) + } + } + + private fun formatDuration(m: Long): String { + val s = (m / 1000) % 60; val min = (m / 60000) % 60; val h = m / 3600000 + return when { h > 0 -> "${h}h ${min}m"; min > 0 -> "${min}m ${s}s"; else -> "${s}s" } + } + + override fun init() { + restorePersistence() + createNotificationChannels() + + // NATIVE HOOKS: Ensuring Snapchat never sees the app as "In Background" + if ((autoOpenConfig.allowRunningInBackground as PropertyValue).get()) { + runCatching { + findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply { + hook("appStateChanged", HookStage.BEFORE) { param -> + val state = param.arg(0).toString() + if (state == "INACTIVE" || state == "BACKGROUND") param.setResult(null) + } + } + findClass("com.snapchat.client.network_manager.NetworkManager\$CppProxy").apply { + hook("onAppForegrounded", HookStage.BEFORE) { param -> param.setResult(null) } + hook("onAppBackgrounded", HookStage.BEFORE) { param -> param.setResult(null) } + } + } + } + + setupReceivers() + startEngineWorker() + setupDetector() + } + + private fun startEngineWorker() { + engineJob = this@AutoOpenSnaps.context.coroutineScope.launch(engineDispatcher) { + while (engineActive.get()) { + val item = try { snapChannel.receive() } catch (e: Exception) { break } + + while (isPaused.get() && engineActive.get()) { + currentStatusText = "Paused"; updateStatusNotification(); delay(500) + } + if (!engineActive.get()) break + + updateStatusNotification() + if (!validateEnvironmentalConstraints()) { + synchronized(queuedSnaps) { queuedSnaps.remove(item) } + continue + } + + // SPEED OPTIMIZATION: Instant switch (40ms) when stealth is off + val isSafe = (autoOpenConfig.safeProcessing as PropertyValue).get() + if (lastConversationId != null && lastConversationId != item.conversationId) { + delay(if (isSafe) (autoOpenConfig.delayBetweenConversations as PropertyValue).get().toLong() else 40L) + } + lastConversationId = item.conversationId + + processSnapItem(item) + lastSnapProcessedAt.set(System.currentTimeMillis()) + + // HIGH SPEED: 10ms floor for 20+ snaps/s + val baseDelay = if (currentSpeedText == "Throttled") 3000L else (autoOpenConfig.delayBetweenSnaps as PropertyValue).get().toLong() + if (isSafe) { + delay(Random.nextLong(baseDelay, baseDelay + 200)) + } else { + delay(baseDelay.coerceAtMost(10)) + } + + if (synchronized(queuedSnaps) { queuedSnaps.isEmpty() }) { + currentStatusText = "Monitoring..." + updateStatusNotification() + } + } + } + } + + private suspend fun processSnapItem(item: SnapQueueItem) { + currentStatusText = "Active"; updateStatusNotification() + var success = false + val startTime = System.currentTimeMillis() + for (i in 0 until (autoOpenConfig.retryAttempts as PropertyValue).get()) { + if (isPaused.get() || !engineActive.get() || autoOpenConfig.globalState == false) break + + if (messaging.conversationManager == null) { + runCatching { this@AutoOpenSnaps.context.messagingBridge.triggerSessionStart() } + delay(1000) + } + + success = withContext(Dispatchers.IO) { performOpen(item) } + if (success) { + // IMPORTANT: Item only removed after successful processing to ensure Stats sync + synchronized(queuedSnaps) { queuedSnaps.remove(item) } + sessionProcessed.incrementAndGet(); totalProcessed.incrementAndGet(); recordSpeedTimestamp() + val duration = System.currentTimeMillis() - startTime + averageProcessingTime.set((averageProcessingTime.get() * 0.7 + duration * 0.3).toLong()) + triggerLazySave(); break + } + delay((autoOpenConfig.retryDelay as PropertyValue).get().toLong()) + } + if (!success && !isPaused.get() && engineActive.get()) { + logError("Engine failed to open Snap: ${item.messageId}") + synchronized(queuedSnaps) { queuedSnaps.remove(item) } + currentStatusText = "Failed: ${item.senderName}"; updateStatusNotification() + openedSnapsIds.remove(item.messageId) + } + } + + private suspend fun performOpen(item: SnapQueueItem): Boolean { + val manager = messaging.conversationManager ?: return false + return suspendCancellableCoroutine { cont -> + runCatching { + manager.updateMessage(item.conversationId, item.messageId, MessageUpdate.READ) { result -> + if (result == null || result == "DUPLICATEREQUEST") { cont.resume(true) } + else if (item.serverMessageId != 0L) { + manager.updateMessage(item.conversationId, item.serverMessageId, MessageUpdate.READ) { serverResult -> + cont.resume(serverResult == null || serverResult == "DUPLICATEREQUEST") + } + } else { cont.resume(false) } + } + }.onFailure { logError("Bridge Error", it); cont.resume(false) } + } + } + + private suspend fun validateEnvironmentalConstraints(): Boolean { + while (engineActive.get()) { + if (autoOpenConfig.globalState == false || isPaused.get()) return false + val isWifi = isWifiConnected() + val isIdle = isDeviceIdle() + val onlyIdle = (autoOpenConfig.onlyWhenIdle as PropertyValue).get() + val inSleepWindow = if (onlyIdle) isInsideSleepWindow() else false + + val wifiStop = (autoOpenConfig.onlyOnWifi as PropertyValue).get() && !isWifi + val idleStop = onlyIdle && !isIdle && !inSleepWindow + + when { + wifiStop -> { currentStatusText = "Waiting for WiFi..."; delay(5000) } + idleStop -> { currentStatusText = "Waiting for Idle..."; delay(5000) } + else -> { + val thermalActive = (autoOpenConfig.thermalProtection as PropertyValue).get() && isThermalThrottled + currentSpeedText = if (inSleepWindow || thermalActive) "Throttled" else "Full Speed" + return true + } + } + updateStatusNotification() + } + return false + } + + private fun setupDetector() { + this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event -> + if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe + val message = event.message + if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe + val conversationId = message.messageDescriptor?.conversationId?.toString() ?: return@subscribe + val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe + val serverMessageId = message.orderKey ?: 0L + + val contentType = message.messageContent?.contentType + if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe + if (!canUseRule(conversationId)) return@subscribe + if (openedSnapsIds.contains(clientMessageId)) return@subscribe + openedSnapsIds.add(clientMessageId) + + val senderId = message.senderId?.toString() ?: "unknown" + val item = SnapQueueItem(conversationId, clientMessageId, serverMessageId, senderId, getSenderDisplayName(senderId), getConversationType(conversationId, senderId), getSnapContentType(contentType)) + + synchronized(queuedSnaps) { queuedSnaps.add(item) } + snapChannel.trySend(item) + + acquireWakeLock(); updateStatusNotification(); triggerLazySave() + } + } + + private fun triggerLazySave() { + needsSaving.set(true) + if (isSaving.compareAndSet(false, true)) { + this@AutoOpenSnaps.context.coroutineScope.launch(Dispatchers.IO) { + while (needsSaving.get() && engineActive.get()) { + needsSaving.set(false); saveQueueToDisk(); delay(LAZY_SAVE_INTERVAL_MS) + } + isSaving.set(false) + } + } + } + + private fun saveQueueToDisk() { + prefs.edit { putInt(PREF_TOTAL_OPENED, totalProcessed.get()); putLong(PREF_SESSION_START, sessionStartTime.get()) } + } + + private fun restorePersistence() { + val savedStartTime = prefs.getLong(PREF_SESSION_START, 0) + if (System.currentTimeMillis() - savedStartTime > 21600000) return + totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)); sessionStartTime.set(savedStartTime) + } + + private fun isWifiConnected(): Boolean { + val cm = this@AutoOpenSnaps.context.androidContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + return cm.getNetworkCapabilities(cm.activeNetwork)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + } + + private fun isDeviceIdle(): Boolean = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode + private fun isInsideSleepWindow(): Boolean { + val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + return hour >= 23 || hour <= 6 + } + + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + wakeLock = (this@AutoOpenSnaps.context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { acquire(8 * 60 * 60 * 1000L) } + } + + private fun releaseWakeLock() { if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.createNotificationChannel(NotificationChannel("auto_open_status", "Auto-Open Status", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) }) } } private fun updateStatusNotification(force: Boolean = false) { - val currentTime = System.currentTimeMillis(); val lastUpdate = lastNotificationUpdate.get() - val remaining = synchronized(queuedSnaps) { queuedSnaps.size } - if (!isScreenOn.get() && !force) return - if (!force && (currentTime - lastUpdate) < notificationUpdateDelay) { + val now = System.currentTimeMillis() + if (!force && (now - lastNotificationUpdate) < notificationUpdateDelay) { if (pendingNotificationUpdate.compareAndSet(false, true)) { - context.coroutineScope.launch { delay(notificationUpdateDelay - (currentTime - lastUpdate)); pendingNotificationUpdate.set(false); updateStatusNotificationInternal() } + this@AutoOpenSnaps.context.coroutineScope.launch { delay(notificationUpdateDelay - (now - lastNotificationUpdate)); updateStatusNotificationInternal() } } return } - lastNotificationUpdate.set(currentTime); updateStatusNotificationInternal() + updateStatusNotificationInternal() } - private var lastNotificationStateHash: Int = 0 - private fun updateStatusNotificationInternal() { + if (!engineActive.get()) return val processed = sessionProcessed.get() val total = totalProcessed.get() val remaining = synchronized(queuedSnaps) { queuedSnaps.size } - - val currentStateHash = Objects.hash(processed, total, remaining, currentStatusText, isPaused.get()) - if (currentStateHash == lastNotificationStateHash && remaining == 0) return - lastNotificationStateHash = currentStateHash - - if (total <= 0 && remaining <= 0 && processed <= 0) return - val isWorking = remaining > 0 - val isCompact = config.compactNotification.get() == true + val speed = if (isWorking) getSnapsPerSecond() else 0.0 + + lastNotificationUpdate = System.currentTimeMillis(); pendingNotificationUpdate.set(false) + val sessionTotal = processed + remaining - val speed = getSnapsPerSecond() val progressPercent = if (sessionTotal > 0) (processed * 100) / sessionTotal else 0 - val eta = if (isWorking && !isCurrentlyWaiting && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..." - - val builder = Notification.Builder(context.androidContext, "auto_open_snaps") - .setSmallIcon(if (isPaused.get()) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play) - .setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY).setGroupSummary(false) + val eta = if (isWorking && !isPaused.get()) formatDuration(remaining * averageProcessingTime.get()) else "..." + val builder = Notification.Builder(this@AutoOpenSnaps.context.androidContext, "auto_open_status") + .setOngoing(isWorking).setOnlyAlertOnce(true).setGroup(NOTIFICATION_GROUP_KEY) + + // ICON LOGIC: Pause, Monitoring (Sync), or Active (Play) + val iconRes = when { + isPaused.get() -> android.R.drawable.ic_media_pause + !isWorking -> android.R.drawable.ic_popup_sync + else -> android.R.drawable.ic_media_play + } + builder.setSmallIcon(iconRes) builder.setContentTitle("Auto-Open: $currentStatusText") + val isCompact = (autoOpenConfig.compactNotification as PropertyValue).get() if (isWorking) { - builder.setContentText("Opened: $processed │ Queue: $remaining") - builder.setSubText("$progressPercent% • Ends in: ${eta ?: "..."}") + builder.setContentText("Opened: $processed │ Queue: $remaining ($progressPercent%)") + builder.setSubText("Speed: ${String.format(Locale.US, "%.1f", speed)}/s • Ends in: $eta") builder.setProgress(sessionTotal, processed, false) } else { builder.setContentText("$processed Opened Today │ $total Total") @@ -444,7 +368,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A } builder.addAction(Notification.Action.Builder(null, if (isPaused.get()) "Resume" else "Pause", createPendingIntent(ACTION_PAUSE_RESUME)).build()) - builder.addAction(Notification.Action.Builder(null, "Clear Queue", createPendingIntent(ACTION_CLEAR_QUEUE)).build()) + builder.addAction(Notification.Action.Builder(null, "Clear", createPendingIntent(ACTION_CLEAR_QUEUE)).build()) + builder.addAction(Notification.Action.Builder(null, "Stop", createPendingIntent(ACTION_STOP_ENGINE)).build()) if (!isCompact) { val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) } @@ -452,16 +377,17 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A val detailText = buildString { append("QUEUE STATISTICS\n") append("├─ Opened: $processed snaps\n") - append("├─ Queue: $remaining snaps\n") - append("├─ Total Opened: $total snaps\n") - val speedNotion = if (remaining > 0) currentSpeedText else "Idle" - val speedValue = if (remaining > 0) "${String.format("%.1f", speed)}/s" else "0.0/s" - append("└─ Speed: $speedNotion ($speedValue)\n\n") + append("├─ Queue: $remaining snaps • Ends in: $eta\n") + if ((autoOpenConfig.showLifetimeStats as PropertyValue).get()) { + append("├─ Total Opened: $total snaps\n") + } + val speedNotion = if (isWorking) currentSpeedText else "Idle" + val speedValue = "${String.format(Locale.US, "%.1f", speed)}/s" + append("└─ Speed: $speedNotion ($speedValue)\n") - - if (config.showQueuePreview.get()) { - append("\n\nQUEUE PREVIEW\n") - if (isWorking) { + if ((autoOpenConfig.showQueuePreview as PropertyValue).get()) { + append("\nQUEUE PREVIEW\n") + if (isWorking && remaining > 0) { recentSnaps.reversed().forEach { item -> append("• ${item.senderName} │ ${item.conversationType} (${item.contentType})\n") } @@ -473,114 +399,58 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A bigTextStyle.bigText(detailText) builder.setStyle(bigTextStyle) } - + notificationManager.notify(STATUS_NOTIFICATION_ID, builder.build()) } - private fun formatDuration(m: Long): String { - val s = (m / 1000) % 60; val min = (m / 60000) % 60; val h = m / 3600000 - return when { h > 0 -> "${h}h ${min}m"; min > 0 -> "${min}m ${s}s"; else -> "${s}s" } + private fun createPendingIntent(action: String): PendingIntent { + val intent = Intent(action).setPackage(this@AutoOpenSnaps.context.androidContext.packageName) + return PendingIntent.getBroadcast(this@AutoOpenSnaps.context.androidContext, action.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } - private fun shutdownFeature() { - cancelStatusNotification(); releaseWakeLock(); hasBeenActive.set(false); triggerLazySave() - } - - private fun startWakeLockCooldown() { - wakeLockCooldownJob?.cancel() - wakeLockCooldownJob = context.coroutineScope.launch { - delay(30000) - releaseWakeLock() - } - } - - private fun triggerLazySave() { - needsSaving.set(true) - if (isSaving.compareAndSet(false, true)) { - context.coroutineScope.launch(Dispatchers.IO) { - while (needsSaving.get()) { needsSaving.set(false); saveToDiskInternal(); delay(300000) } - isSaving.set(false) + private fun setupReceivers() { + val actionReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + when (intent?.action) { + ACTION_PAUSE_RESUME -> { isPaused.set(!isPaused.get()); updateStatusNotification(force = true) } + ACTION_CLEAR_QUEUE -> { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) } + ACTION_STOP_ENGINE -> shutdownFeature() + Intent.ACTION_BATTERY_CHANGED -> { + val temp = intent.getIntExtra("temperature", 0) / 10f + if (temp >= 40f && !isThermalThrottled) { isThermalThrottled = true; lastThermalThrottleAt = System.currentTimeMillis() } + else if (isThermalThrottled && temp <= 36f && (System.currentTimeMillis() - lastThermalThrottleAt > 600000)) { isThermalThrottled = false } + } + } } } + val filter = IntentFilter().apply { addAction(ACTION_PAUSE_RESUME); addAction(ACTION_CLEAR_QUEUE); addAction(ACTION_STOP_ENGINE); addAction(Intent.ACTION_BATTERY_CHANGED) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + else this@AutoOpenSnaps.context.androidContext.registerReceiver(actionReceiver, filter) } - private fun saveToDiskInternal() { - prefs.edit { - putInt(PREF_TOTAL_OPENED, totalProcessed.get()) - putLong(PREF_SESSION_START, sessionStartTime.get()) - synchronized(queuedSnaps) { putString(PREF_SAVED_QUEUE, gson.toJson(queuedSnaps)) } + private fun recordSpeedTimestamp() { synchronized(snapTimestamps) { snapTimestamps.addLast(System.currentTimeMillis()); if (snapTimestamps.size > 250) snapTimestamps.removeFirst() } } + + private fun shutdownFeature() { + engineActive.set(false) + snapChannel.close() + engineJob?.cancel() + releaseWakeLock() + cancelStatusNotification() + } + + private fun cancelStatusNotification() = notificationManager.cancel(STATUS_NOTIFICATION_ID) + + fun getInterface(): AutoOpenInterface { + return object : AutoOpenInterface.Stub() { + override fun getProcessedCount(): Int = totalProcessed.get() + override fun getQueueItems(): List = synchronized(queuedSnaps) { queuedSnaps.map { gson.toJson(it) } } + override fun reset() { sessionProcessed.set(0); synchronized(queuedSnaps) { queuedSnaps.clear() }; updateStatusNotification(force = true) } } } - private fun restorePersistence() { - val savedStartTime = prefs.getLong(PREF_SESSION_START, 0) - val now = System.currentTimeMillis() - if (now - savedStartTime > 3600000) { - prefs.edit().remove(PREF_SAVED_QUEUE).remove(PREF_TOTAL_OPENED).apply(); return - } - totalProcessed.set(prefs.getInt(PREF_TOTAL_OPENED, 0)) - sessionStartTime.set(savedStartTime) - val savedQueueJson = prefs.getString(PREF_SAVED_QUEUE, null) - if (!savedQueueJson.isNullOrBlank()) { - try { - val restored: List = gson.fromJson(savedQueueJson, object : TypeToken>() {}.type) - synchronized(queuedSnaps) { queuedSnaps.addAll(restored.filter { (now - it.timestamp) < 3600000 }) } - } catch (e: Exception) { prefs.edit().remove(PREF_SAVED_QUEUE).apply() } - } - } - - private fun isInsideSleepWindow(): Boolean { - try { - val window = config.sleepWindow.get().split("-"); if (window.size != 2) return false - val start = window[0].split(":"); val end = window[1].split(":") - val now = Calendar.getInstance().apply { set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) } - val s = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, start[0].toInt()); set(Calendar.MINUTE, start[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) } - val e = Calendar.getInstance().apply { set(Calendar.HOUR_OF_DAY, end[0].toInt()); set(Calendar.MINUTE, end[1].toInt()); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0) } - return if (e.before(s)) now.after(s) || now.before(e) else now.after(s) && now.before(e) - } catch (e: Exception) { return false } - } - - private fun isWifiConnected(): Boolean { - val cm = context.androidContext.getSystemService(ConnectivityManager::class.java) ?: return false - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - cm.allNetworks.any { cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true } - } else { - @Suppress("DEPRECATION") cm.activeNetworkInfo?.type == ConnectivityManager.TYPE_WIFI - } - } - - private fun isDeviceIdle(): Boolean = (context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager).isDeviceIdleMode - - private fun acquireWakeLock() { - wakeLockCooldownJob?.cancel() - if (wakeLock == null) { - val pm = context.androidContext.getSystemService(Context.POWER_SERVICE) as PowerManager - wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "PurrfectSnap:AutoOpen").apply { setReferenceCounted(false) } - wakeLock?.acquire(8 * 60 * 60 * 1000L) - } - } - - private fun releaseWakeLock() { - if (wakeLock?.isHeld == true) wakeLock?.release(); wakeLock = null - } - - private fun createPendingIntent(a: String): PendingIntent { - val i = Intent(a).apply { setPackage(context.androidContext.packageName) } - return PendingIntent.getBroadcast(context.androidContext, a.hashCode(), i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - } - - private fun createNotificationChannels() { - val c = NotificationChannel("auto_open_snaps", "Auto Open Snaps", NotificationManager.IMPORTANCE_LOW).apply { enableVibration(false); setSound(null, null) } - notificationManager.createNotificationChannel(c) - } - - private fun getSenderDisplayName(id: String): String = metadataCache.getOrPut(id) { context.database.getFriendInfo(id)?.let { it.displayName ?: it.mutableUsername } ?: "Unknown" } - - private fun getConversationType(cid: String, sid: String): String = metadataCache.getOrPut("$cid:$sid") { if (context.database.getDMOtherParticipant(cid) != null) "Friend DM" else context.database.getFeedEntryByConversationId(cid)?.feedDisplayName ?: "Group Chat" } - - private fun getSnapContentType(type: ContentType?): String = when (type) { - ContentType.SNAP -> "Photo/Video" - ContentType.EXTERNAL_MEDIA -> "Media" - else -> context.translation["auto_open_snaps.content_type_snap"] ?: "Snap" - } + private fun getSenderDisplayName(userId: String): String = this@AutoOpenSnaps.context.database.getFriendInfo(userId)?.displayName ?: "Unknown" + private fun getConversationType(convId: String, senderId: String): String = if (this@AutoOpenSnaps.context.database.getDMOtherParticipant(convId) != null) "Friend DM" else this@AutoOpenSnaps.context.database.getFeedEntryByConversationId(convId)?.feedDisplayName ?: "Group Chat" + private fun getSnapContentType(type: ContentType?): String = when (type) { ContentType.SNAP -> "Photo/Video"; ContentType.EXTERNAL_MEDIA -> "Media"; else -> "Message" } } + +data class SnapQueueItem(val conversationId: String, val messageId: Long, val serverMessageId: Long, val senderId: String, val senderName: String, val conversationType: String, val contentType: String, val timestamp: Long = System.currentTimeMillis()) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Notifications.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Notifications.kt index b91d77b6..9e6c88f5 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Notifications.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/messaging/Notifications.kt @@ -118,7 +118,7 @@ class Notifications : Feature("Notifications") { val intent = SnapWidgetBroadcastReceiverHelper.create(remoteAction) { putExtra("conversation_id", conversationId) putExtra("notification_id", notificationData.id) - putExtra("client_message_id", message.messageDescriptor!!.messageId!!) + putExtra("client_message_id", message.messageDescriptor!!.messageId!!.toLong()) } val action = Notification.Action.Builder(null, title, PendingIntent.getBroadcast( @@ -160,7 +160,9 @@ class Notifications : Feature("Notifications") { context.event.subscribe(SnapWidgetBroadcastReceiveEvent::class) { event -> val intent = event.intent ?: return@subscribe val conversationId = intent.getStringExtra("conversation_id") ?: return@subscribe - val clientMessageId = intent.getLongExtra("client_message_id", -1) + val clientMessageId = intent.getLongExtra("client_message_id", -1L).takeIf { it != -1L } + ?: intent.getStringExtra("client_message_id")?.toLongOrNull() + ?: intent.getIntExtra("client_message_id", -1).toLong() val notificationId = intent.getIntExtra("notification_id", -1) val updateNotification: (Int, (Notification) -> Unit) -> Unit = { id, notificationBuilder -> @@ -209,10 +211,15 @@ class Notifications : Feature("Notifications") { }) } ACTION_DOWNLOAD -> { - runCatching { - context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false) - }.onFailure { - context.longToast(it) + context.shortToast(context.translation.getCategory("download_processor")["download_started_toast"] ?: "Downloading...") + context.coroutineScope.launch(coroutineDispatcher) { + runCatching { + if (clientMessageId <= 0) throw Exception("Message not found or expired in database.") + context.feature(MediaDownloader::class).downloadMessageId(clientMessageId, isPreview = false) + }.onFailure { + val msg = if (it.message?.contains("not found", true) == true) "Message expired or already viewed." else it.message + context.longToast("Download failed: $msg") + } } } ACTION_MARK_AS_READ -> { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/ConversationToolbox.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/ConversationToolbox.kt index 1053823b..d67f552e 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/ConversationToolbox.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/ConversationToolbox.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.times +import me.eternal.purrfectsnap.common.scripting.JSModule import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/scripting/CoreScriptRuntime.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/scripting/CoreScriptRuntime.kt index 847ba6e4..db6a3187 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/scripting/CoreScriptRuntime.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/scripting/CoreScriptRuntime.kt @@ -1,5 +1,6 @@ package me.eternal.purrfectsnap.core.scripting +import me.eternal.purrfectsnap.common.scripting.JSModule import me.eternal.purrfectsnap.bridge.scripting.AutoReloadListener import me.eternal.purrfectsnap.common.logger.AbstractLogger import me.eternal.purrfectsnap.common.scripting.ScriptRuntime @@ -7,6 +8,11 @@ import me.eternal.purrfectsnap.common.scripting.bindings.BindingSide import me.eternal.purrfectsnap.core.ModContext import me.eternal.purrfectsnap.core.scripting.impl.* +/** + * Core-side implementation of the [ScriptRuntime]. + * Manages script lifecycle synchronized with the JNI bridge connection state + * to prevent race conditions during early-init hooks. + */ class CoreScriptRuntime( private val modContext: ModContext, logger: AbstractLogger, @@ -15,9 +21,18 @@ class CoreScriptRuntime( androidContext = modContext.androidContext, logger = logger ) { - // we assume that the bridge is reloaded the next time we connect to it + // Indicates if the bridge has been reloaded at least once in this session private var isBridgeReloaded = false + /** + * Bridge connection status. Use [isBridgeConnected] to guard JNI-dependent operations. + */ + @Volatile + private var isBridgeConnected = false + + /** + * Initializes the scripting environment and establishes bridge-aware lifecycle observers. + */ fun init() { buildModuleObject = { module -> putConst("currentSide", this, BindingSide.CORE.key) @@ -32,11 +47,14 @@ class CoreScriptRuntime( modContext.bridgeClient.addOnConnectedCallback(initNow = true) { modContext.bridgeClient.getScriptingInterface()?.let { scriptingInterface -> + logger.info("JNI Bridge established. Initializing scripts...") scripting = scriptingInterface + isBridgeConnected = true if (!isBridgeReloaded) { scriptingInterface.enabledScripts.forEach { path -> runCatching { + logger.verbose("Loading script: $path") load(path, scriptingInterface.getScriptContent(path)) }.onFailure { logger.error("Failed to load script $path", it) @@ -46,6 +64,7 @@ class CoreScriptRuntime( scriptingInterface.registerAutoReloadListener(object : AutoReloadListener.Stub() { override fun restartApp() { + logger.info("Script change detected. Soft-restarting app...") modContext.softRestartApp() } }) @@ -57,7 +76,18 @@ class CoreScriptRuntime( if (!isBridgeReloaded) { isBridgeReloaded = true } + } ?: run { + isBridgeConnected = false + logger.error("JNI Bridge callback triggered but interface is null.") } } } + + /** + * Safely iterates over loaded modules only when the JNI bridge is confirmed connected. + */ + override fun eachModule(f: JSModule.() -> Unit) { + if (!isBridgeConnected) return + super.eachModule(f) + } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt index 1e045fd0..f5505a30 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/menu/impl/FriendFeedInfoMenu.kt @@ -43,6 +43,7 @@ import me.eternal.purrfectsnap.common.data.ContentType import me.eternal.purrfectsnap.common.data.FriendLinkType import me.eternal.purrfectsnap.common.database.impl.ConversationMessage import me.eternal.purrfectsnap.common.database.impl.FriendInfo +import me.eternal.purrfectsnap.common.scripting.JSModule import me.eternal.purrfectsnap.common.scripting.ui.EnumScriptInterface import me.eternal.purrfectsnap.common.scripting.ui.InterfaceManager import me.eternal.purrfectsnap.common.scripting.ui.ScriptInterface diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/opera/ParamMap.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/opera/ParamMap.kt index 630e3dda..81288de4 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/opera/ParamMap.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/wrapper/impl/media/opera/ParamMap.kt @@ -30,6 +30,38 @@ class ParamMap(obj: Any?) : AbstractWrapper(obj) { return concurrentHashMap.keys.any { k: Any -> k.toString() == key } } + fun getStoryIdentity(): String? { + return this["STORY_ID"]?.toString() + ?.takeIf { it.isNotBlank() && it != "null" } + ?: this["TOPIC_SNAP_CREATOR_USER_ID"]?.toString() + ?.takeIf { it.isNotBlank() && it != "null" } + ?: this["STORY_SNAP_ID"]?.toString() + ?.substringBefore("_") + ?.takeIf { it.isNotBlank() && it != "null" } + ?: this["PLAYLIST_V2_GROUP"]?.toString() + ?.substringAfter("storyUserId=", "") + ?.substringBefore(",") + ?.takeIf { it.isNotBlank() && it != "null" } + ?: this["PLAYABLE_STORY_SNAP_RECORD"]?.toString() + ?.substringAfter("storyUserId=", "") + ?.substringBefore(",") + ?.takeIf { it.isNotBlank() && it != "null" } + } + + fun getStorySnapIndex(): Int? { + return (this["STORY_SNAP_INDEX"] as? Int) + ?: (this["snap_index_in_story"]?.toString()?.toIntOrNull()) + ?: (this["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull()) + ?: (this["REPLAYABLE_STORY_SNAP_RECORD"]?.toString()?.substringAfter("snapIndex=", "")?.substringBefore(",")?.toIntOrNull()) + } + + fun getStorySnapTotal(): Int { + return (this["STORY_SNAP_TOTAL"] as? Int) + ?: (this["snap_story_length"]?.toString()?.toIntOrNull()) + ?: (this["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull()) + ?: 0 + } + override fun toString(): String { return concurrentHashMap.toString() } diff --git a/native/rust/Cargo.lock b/native/rust/Cargo.lock index d2e497fb..1b2b1eb2 100644 --- a/native/rust/Cargo.lock +++ b/native/rust/Cargo.lock @@ -107,9 +107,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bzip2" @@ -563,9 +563,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-traits" @@ -821,18 +821,28 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -948,22 +958,22 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "typenum" diff --git a/native/rust/src/config.rs b/native/rust/src/config.rs index ab781640..50e30ab1 100644 --- a/native/rust/src/config.rs +++ b/native/rust/src/config.rs @@ -8,12 +8,17 @@ pub fn native_config() -> NativeConfig { NATIVE_CONFIG.lock().unwrap().as_ref().expect("NativeConfig not loaded").clone() } +/// Native configuration structure mirrored from 'NativeConfig.kt'. +/// +/// CRITICAL: Fields must maintain 1:1 parity with the Kotlin implementation. +/// Mismatches in field names, types, or order will result in a JNI SIGABRT. #[derive(Debug, Clone)] pub(crate) struct NativeConfig { pub disable_bitmoji: bool, pub disable_metrics: bool, pub valdi_hooks: bool, pub custom_emoji_font_path: Option, + pub debug_font_redirect: bool, } impl NativeConfig { @@ -41,6 +46,7 @@ impl NativeConfig { disable_metrics: get_boolean!("disableMetrics"), valdi_hooks: get_boolean!("valdiHooks"), custom_emoji_font_path: get_string!("customEmojiFontPath"), + debug_font_redirect: get_boolean!("debugFontRedirect"), }) } } diff --git a/native/rust/src/modules/custom_font_hook.rs b/native/rust/src/modules/custom_font_hook.rs index f76d3f9a..7aeded9d 100644 --- a/native/rust/src/modules/custom_font_hook.rs +++ b/native/rust/src/modules/custom_font_hook.rs @@ -1,7 +1,5 @@ use std::{cell::Cell, ffi::{CStr, CString}}; - use nix::libc::{self, c_uint}; - use crate::{config, def_hook, dobby_hook_sym}; thread_local! { @@ -23,6 +21,8 @@ fn should_redirect_font(pathname: &str) -> bool { file_name.contains("emoji") || file_name == "noto_color_emoji.ttf" || file_name == "samsungcoloremoji.ttf" + || file_name == "coloremojifont.ttf" + || file_name == "coloros_color_emoji.ttf" ) } @@ -33,7 +33,7 @@ fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option { 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) }; + let fd = unsafe { libc::openat(libc::AT_FDCWD, c_font_path.as_ptr() as *const libc::c_char, flags, mode) }; guard.set(was_active); fd }); @@ -41,6 +41,9 @@ fn open_custom_font_fd(flags: i32, mode: c_uint) -> Option { debug!("redirected emoji font open to {}", font_path); Some(fd) } else { + if config::native_config().debug_font_redirect { + panic!("Failed to open custom emoji font: {}", font_path); + } debug!("failed to open custom emoji font path (fd={}): {}", fd, font_path); None } @@ -61,7 +64,7 @@ def_hook!( } if !path.is_null() { - if let Ok(pathname) = CStr::from_ptr(path).to_str() { + if let Ok(pathname) = unsafe { CStr::from_ptr(path as *const libc::c_char) }.to_str() { if should_redirect_font(pathname) { if let Some(fd) = open_custom_font_fd(flags, mode) { return fd; @@ -74,10 +77,33 @@ def_hook!( } ); +def_hook!( + openat_hook, + i32, + |dirfd: i32, path: *const u8, flags: i32, mode: c_uint| { + if FONT_REDIRECT_IN_PROGRESS.with(|guard| guard.get()) { + return openat_hook_original.unwrap()(dirfd, path, flags, mode); + } + + if !path.is_null() { + if let Ok(pathname) = unsafe { CStr::from_ptr(path as *const libc::c_char) }.to_str() { + if should_redirect_font(pathname) { + if let Some(fd) = open_custom_font_fd(flags, mode) { + return fd; + } + } + } + } + + openat_hook_original.unwrap()(dirfd, path, flags, mode) + } +); + pub fn init() { if config::native_config().custom_emoji_font_path.is_none() { return; } dobby_hook_sym!("libc.so", "open", open_hook); + dobby_hook_sym!("libc.so", "openat", openat_hook); } diff --git a/native/rust/src/modules/util/valdi_utils.rs b/native/rust/src/modules/util/valdi_utils.rs index 6dc4cd31..30733b6e 100644 --- a/native/rust/src/modules/util/valdi_utils.rs +++ b/native/rust/src/modules/util/valdi_utils.rs @@ -42,6 +42,9 @@ pub struct ValdiModule { impl ValdiModule { pub fn parse(buffer: Vec) -> Result { + if buffer.len() < 8 { + return Err(Error::new(std::io::ErrorKind::InvalidData, "Buffer too small")); + } let mut offset = 0; let magic = u32::from_be_bytes([buffer[offset], buffer[offset + 1], buffer[offset + 2], buffer[offset + 3]]); @@ -62,14 +65,13 @@ impl ValdiModule { } fn read_u32(buffer: &Vec, offset: &mut usize) -> Result<(u32, bool), Error> { - let b1 = buffer[*offset] as u32; - let b2 = buffer[*offset + 1] as u32; - let b3 = buffer[*offset + 2] as u32; - let b4 = (buffer[*offset + 3] & 0x7f) as u32; - let has_padding = (buffer[*offset + 3] & 0x80) != 0; + let bytes = [buffer[*offset], buffer[*offset + 1], buffer[*offset + 2], buffer[*offset + 3]]; + let value = u32::from_be_bytes(bytes); + let has_padding = (value & 0x80000000) != 0; + let tag_size = value & 0x7FFFFFFF; *offset += 4; - Ok((b1 | (b2 << 8) | (b3 << 16) | (b4 << 24), has_padding)) + Ok((tag_size, has_padding)) } let (tag_size, has_padding) = read_u32(&buffer, &mut offset)?; @@ -98,10 +100,8 @@ impl ValdiModule { let mut tag_buffer = Vec::new(); fn write_u32(buffer: &mut Vec, value: u32, has_padding: bool) { - buffer.push(value as u8); - buffer.push(((value >> 8) & 0xff) as u8); - buffer.push(((value >> 16) & 0xff) as u8); - buffer.push(((value >> 24) & 0x7f) as u8 | if has_padding { 0x80 } else { 0x00 }); + let encoded_value = (value & 0x7FFFFFFF) | if has_padding { 0x80000000 } else { 0 }; + buffer.extend_from_slice(&encoded_value.to_be_bytes()); } fn write_tag(buffer: &mut Vec, tag: ModuleTag) { @@ -125,7 +125,7 @@ impl ValdiModule { let mut buffer = Vec::new(); buffer.extend_from_slice(&[0x33, 0xc6, 0, 1]); - buffer.extend_from_slice(&(tag_buffer.len() as u32).to_le_bytes()); + buffer.extend_from_slice(&(tag_buffer.len() as u32).to_be_bytes()); buffer.extend(tag_buffer); buffer diff --git a/native/rust/src/modules/valdi_hook.rs b/native/rust/src/modules/valdi_hook.rs index d910ecdd..298fabac 100644 --- a/native/rust/src/modules/valdi_hook.rs +++ b/native/rust/src/modules/valdi_hook.rs @@ -16,7 +16,10 @@ def_hook!( if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) { return buffer.len() as i32; } - aasset_get_length_original.unwrap()(arg0) + if let Some(original) = aasset_get_length_original { + return original(arg0); + } + 0 } ); @@ -27,7 +30,10 @@ def_hook!( if let Some(buffer) = AASSET_MAP.lock().unwrap().get(&(arg0 as usize)) { return buffer.as_ptr() as *const c_void; } - aasset_get_buffer_original.unwrap()(arg0) + if let Some(original) = aasset_get_buffer_original { + return original(arg0); + } + std::ptr::null() } ); @@ -35,53 +41,89 @@ def_hook!( aasset_manager_open, *mut c_void, |arg0: *mut c_void, arg1: *const u8, arg2: i32| { - let handle = aasset_manager_open_original.unwrap()(arg0, arg1, arg2); + let original_fn = match aasset_manager_open_original { + Some(f) => f, + None => return std::ptr::null_mut(), + }; - let path = std::ffi::CStr::from_ptr(arg1).to_str().unwrap_or_default(); - if !handle.is_null() && path.starts_with("bridge_observables") { - let asset_buffer = aasset_get_buffer_original.unwrap()(handle); - let asset_length = aasset_get_length_original.unwrap()(handle); - debug!("asset buffer: {:p}, length: {}", asset_buffer, asset_length); + let handle = original_fn(arg0, arg1, arg2); + if handle.is_null() { + return handle; + } - let loader_data = LOADER_DATA.lock().unwrap().clone().expect("No loader data"); + let path_cstr = unsafe { std::ffi::CStr::from_ptr(arg1 as *const std::os::raw::c_char) }; + let path = path_cstr.to_str().unwrap_or_default(); + + // Only target compressed Valdi bridge observables + if path.ends_with(".zst") && path.contains("bridge_observables") { + let get_buffer_fn = match aasset_get_buffer_original { + Some(f) => f, + None => return handle, + }; + let get_length_fn = match aasset_get_length_original { + Some(f) => f, + None => return handle, + }; - let archive_buffer: Vec = std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec(); - let decompressed = zstd::stream::decode_all(&archive_buffer[..]).expect("Failed to decompress valdi archive"); - let mut valdi_module = ValdiModule::parse(decompressed).expect("Failed to parse valdi module"); - - let mut tags = valdi_module.get_tags(); - let mut new_tags = Vec::new(); - - for (tag1, _) in tags.iter_mut() { - let name = tag1.to_string().unwrap_or_default(); - if !name.ends_with("src/utils/converter.js") { - continue; - } - - let old_file_name = name.split_once(".").unwrap().0.to_owned() + rand::random::().to_string().as_str(); - tag1.set_buffer((old_file_name.to_owned() + ".js").as_bytes().to_vec()); - let original_module_path = path.split_once(".").unwrap().0.to_owned() + "/" + &old_file_name; - - let hooked_module = format!("{};module.exports = require(\"{}\");", loader_data, original_module_path); - - new_tags.push( - ( - ModuleTag::new(true, name.as_bytes().to_vec()), - ModuleTag::new(true, hooked_module.as_bytes().to_vec()) - ) - ); - - debug!("Valdi loader injected in {}", name); - break; + let asset_buffer = get_buffer_fn(handle); + let asset_length = get_length_fn(handle); + + if asset_buffer.is_null() || asset_length <= 0 { + return handle; } - tags.extend(new_tags); - valdi_module.set_tags(tags); + let loader_data = match LOADER_DATA.lock().unwrap().clone() { + Some(data) => data, + None => { + warn!("Valdi loader data not yet initialized for {}", path); + return handle; + } + }; - let compressed = valdi_module.to_bytes(); - let compressed = zstd::stream::encode_all(&compressed[..], 3).expect("Failed to compress"); + let archive_buffer: Vec = unsafe { + std::slice::from_raw_parts(asset_buffer as *const u8, asset_length as usize).to_vec() + }; + + let decompressed = match zstd::stream::decode_all(&archive_buffer[..]) { + Ok(data) => data, + Err(e) => { + error!("Failed to decompress Valdi archive {}: {}", path, e); + return handle; + } + }; - AASSET_MAP.lock().unwrap().insert(handle as usize, compressed); + let valdi_module = match ValdiModule::parse(decompressed) { + Ok(module) => module, + Err(e) => { + error!("Failed to parse Valdi module {}: {}", path, e); + return handle; + } + }; + + let mut tags = valdi_module.get_tags(); + let mut found = false; + + for (tag1, tag2) in tags.iter_mut() { + let name = tag1.to_string().unwrap_or_default(); + if name.ends_with("src/utils/converter.js") { + let mut hooked_content = loader_data.as_bytes().to_vec(); + hooked_content.extend_from_slice(tag2.get_buffer()); + *tag2 = ModuleTag::new(true, hooked_content); + found = true; + debug!("Valdi loader prepended to {}", name); + break; + } + } + + if found { + let compressed = valdi_module.to_bytes(); + match zstd::stream::encode_all(&compressed[..], 3) { + Ok(compressed_data) => { + AASSET_MAP.lock().unwrap().insert(handle as usize, compressed_data); + }, + Err(e) => error!("Failed to re-compress Valdi module: {}", e), + } + } } handle } @@ -89,16 +131,19 @@ def_hook!( def_hook!( aasset_close, - c_void, + (), |handle: *mut c_void| { AASSET_MAP.lock().unwrap().remove(&(handle as usize)); - aasset_close_original.unwrap()(handle) + if let Some(original) = aasset_close_original { + original(handle); + } } ); pub fn set_valdi_loader(mut env: JNIEnv, _: *mut c_void, code: JString) { - let new_code = get_jni_string(&mut env, code).expect("Failed to get loader code"); - LOADER_DATA.lock().unwrap().replace(new_code); + if let Ok(new_code) = get_jni_string(&mut env, code) { + LOADER_DATA.lock().unwrap().replace(new_code); + } } pub fn init() { diff --git a/native/src/main/kotlin/me/eternal/purrfectsnap/nativelib/NativeConfig.kt b/native/src/main/kotlin/me/eternal/purrfectsnap/nativelib/NativeConfig.kt index 6d8eefa7..24b57a08 100644 --- a/native/src/main/kotlin/me/eternal/purrfectsnap/nativelib/NativeConfig.kt +++ b/native/src/main/kotlin/me/eternal/purrfectsnap/nativelib/NativeConfig.kt @@ -1,5 +1,12 @@ package me.eternal.purrfectsnap.nativelib +/** + * Configuration schema for the native layer. + * + * CRITICAL: This class MUST maintain 1:1 field parity with 'native/rust/src/config.rs'. + * Any modification to field names, types, or order without a corresponding change + * in the Rust implementation will cause a JNI SIGABRT (crash on launch). + */ data class NativeConfig( @JvmField val disableBitmoji: Boolean = false, @@ -9,4 +16,6 @@ data class NativeConfig( val valdiHooks: Boolean = false, @JvmField val customEmojiFontPath: String? = null, + @JvmField + val debugFontRedirect: Boolean = false, ) From 83e886526610f2547bc2e9859a825f68fb9f8235 Mon Sep 17 00:00:00 2001 From: DarkKnight2122 <145188583+DarkKnight2122@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:17:51 +0530 Subject: [PATCH 2/5] UI fixes and stabilization --- .../download/DownloadProcessor.kt | 5 +++ .../purrfectsnap/download/FFMpegProcessor.kt | 4 ++ .../impl/downloader/MediaDownloader.kt | 40 +++++++++---------- .../impl/experiments/AutoOpenSnaps.kt | 2 +- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt index 6a1a7d84..dd0dc591 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/DownloadProcessor.kt @@ -121,6 +121,7 @@ class DownloadProcessor ( inputFile.outputStream().use { bitmap.compress(compressFormat, 100, it) } + bitmap.recycle() fileType = FileType.fromFile(inputFile) } } @@ -673,6 +674,10 @@ class DownloadProcessor ( mergedBitmap.compress(compressFormat, 100, it) } + originalBitmap.recycle() + overlayBitmap.recycle() + mergedBitmap.recycle() + saveMediaToGallery(pendingTask, mergedImage, downloadMetadata) mergedImage.delete() renamedOverlayMedia.delete() diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt index 86c0b053..5e4b9dd0 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/download/FFMpegProcessor.kt @@ -92,6 +92,10 @@ class FFMpegProcessor( private val sharedExecutor = Executors.newSingleThreadExecutor() + protected fun finalize() { + runCatching { sharedExecutor.shutdown() } + } + private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine { val stringBuilder = StringBuilder() arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt index 9d0b76b5..9302bba7 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/downloader/MediaDownloader.kt @@ -76,6 +76,7 @@ import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap import me.eternal.purrfectsnap.core.wrapper.impl.media.toKeyPair import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlin.math.absoluteValue @@ -91,6 +92,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private set @Volatile private var pendingBatchDownloadIndices: MutableList? = null + private val batchLock = Any() + @Volatile private var batchForceAllowDuplicate: Boolean = false private val translations by lazy { @@ -107,12 +110,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private fun logVerbose(msg: String) = this@MediaDownloader.context.log.verbose("[MediaDownloader] $msg") private fun logError(msg: String, e: Throwable? = null) = if (e != null) this@MediaDownloader.context.log.error("[MediaDownloader] $msg", e) else this@MediaDownloader.context.log.error("[MediaDownloader] $msg") - @Volatile - private var batchTotalCount: Int = 0 - @Volatile - private var batchSuccessCount: Int = 0 - @Volatile - private var batchFailureCount: Int = 0 + private val batchTotalCount = AtomicInteger(0) + private val batchSuccessCount = AtomicInteger(0) + private val batchFailureCount = AtomicInteger(0) @Volatile private var initialBatchStoryIdentity: String? = null @@ -179,11 +179,11 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp }.onFailure { logError("Post-Processing Logic Failed for $outputFile", it) } if (isBatch) { - batchSuccessCount++ + batchSuccessCount.incrementAndGet() if (downloadLogging.contains("success")) { modCtx.inAppOverlay.showStatusToast( icon = Icons.Outlined.DownloadDone, - text = translations.format("batch_progress_toast", "current" to (batchSuccessCount + batchFailureCount).toString(), "total" to batchTotalCount.toString()), + text = translations.format("batch_progress_toast", "current" to (batchSuccessCount.get() + batchFailureCount.get()).toString(), "total" to batchTotalCount.get().toString()), durationMs = 1300 ) } @@ -207,7 +207,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp override fun onFailure(message: String, throwable: String?) { if (!downloadLogging.contains("failure")) return val errorText = translations[if (message == "Failed to download") "failed_generic_toast" else message] ?: message - if (isBatch) { batchFailureCount++; return } + if (isBatch) { batchFailureCount.incrementAndGet(); return } if (modCtx.isMainActivityPaused) modCtx.shortToast(errorText) modCtx.inAppOverlay.showStatusToast(Icons.Outlined.ErrorOutline, errorText, 1300) } @@ -283,9 +283,9 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val mediaInfoMap = lastSeenMediaInfoMap ?: return val modCtx = this@MediaDownloader.context - batchTotalCount = indices.size - batchSuccessCount = 0; batchFailureCount = 0 - pendingBatchDownloadIndices = indices + batchTotalCount.set(indices.size) + batchSuccessCount.set(0); batchFailureCount.set(0) + synchronized(batchLock) { pendingBatchDownloadIndices = indices } batchForceAllowDuplicate = allowDuplicate initialBatchStoryIdentity = paramMap.getStoryIdentity() @@ -297,7 +297,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp modCtx.coroutineScope.launch { processNextBatchDownload(paramMap, mediaInfoMap) } } else { val jumped = modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(targetIndex, totalCount) - if (!jumped) { pendingBatchDownloadIndices = null; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } + if (!jumped) { synchronized(batchLock) { pendingBatchDownloadIndices = null }; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } } } @@ -305,13 +305,13 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp runCatching { handleOperaMedia(paramMap, mediaInfoMap, forceDownload = true, forceAllowDuplicate = batchForceAllowDuplicate, isBatch = true) }.onFailure { - batchFailureCount++ - if (batchSuccessCount + batchFailureCount == batchTotalCount) flushPendingMergeAndComplete() + batchFailureCount.incrementAndGet() + if (batchSuccessCount.get() + batchFailureCount.get() == batchTotalCount.get()) flushPendingMergeAndComplete() } } private suspend fun processNextBatchDownload(paramMap: ParamMap, mediaInfoMap: Map) { - val queue = pendingBatchDownloadIndices ?: return + val queue = synchronized(batchLock) { pendingBatchDownloadIndices } ?: return if (queue.isEmpty()) return val modCtx = this@MediaDownloader.context @@ -323,7 +323,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp val currentIndex = paramMap.getStorySnapIndex() ?: -1 if (currentIndex != queue.first()) return - queue.removeAt(0) + synchronized(batchLock) { queue.removeAt(0) } downloadSingleSnap(paramMap, mediaInfoMap) if (queue.isNotEmpty()) { @@ -333,7 +333,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ val jumped = runCatching { modCtx.feature(OperaStoryOverlay::class).requestJumpToSnap(queue.first(), totalCount) }.getOrNull() == true if (!jumped && retryCount < 1) tryJump(retryCount + 1) - else if (!jumped) { pendingBatchDownloadIndices = null; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } + else if (!jumped) { synchronized(batchLock) { pendingBatchDownloadIndices = null }; modCtx.shortToast(translations["batch_download_jump_failed_toast"] ?: "Jump Failed") } }, if (retryCount == 0) 120L else 220L) } tryJump() @@ -343,8 +343,8 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp private fun flushPendingMergeAndComplete() { val modCtx = this@MediaDownloader.context - pendingBatchDownloadIndices = null - modCtx.shortToast(if (batchFailureCount == 0) translations["batch_download_complete_toast"] ?: "Batch Complete" else "Batch complete: $batchSuccessCount succeeded, $batchFailureCount failed") + synchronized(batchLock) { pendingBatchDownloadIndices = null } + modCtx.shortToast(if (batchFailureCount.get() == 0) translations["batch_download_complete_toast"] ?: "Batch Complete" else "Batch complete: ${batchSuccessCount.get()} succeeded, ${batchFailureCount.get()} failed") } fun showLastOperaDebugMediaInfo() { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt index e2837409..f413b786 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/experiments/AutoOpenSnaps.kt @@ -373,7 +373,7 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A if (!isCompact) { val recentSnaps = synchronized(queuedSnaps) { queuedSnaps.takeLast(5) } - val bigTextStyle = Notification.BigTextStyle().setSummaryText("") + val bigTextStyle = Notification.BigTextStyle().setSummaryText(null) val detailText = buildString { append("QUEUE STATISTICS\n") append("├─ Opened: $processed snaps\n") From 3e07d4c419983b6014cbe51e7762397073016b54 Mon Sep 17 00:00:00 2001 From: C R E S T <217463890+imCrest@users.noreply.github.com> Date: Tue, 14 Apr 2026 19:00:20 +0530 Subject: [PATCH 3/5] Refactor SPOTLIGHT_5TH_TAB_ENABLED property override --- .../core/features/impl/ConfigurationOverride.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt index 715fa180..a9d66f63 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ConfigurationOverride.kt @@ -1,6 +1,7 @@ package me.eternal.purrfectsnap.core.features.impl import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.features.impl.messaging.Messaging import me.eternal.purrfectsnap.core.util.hook.HookStage import me.eternal.purrfectsnap.core.util.hook.Hooker @@ -165,7 +166,10 @@ class ConfigurationOverride : Feature("Configuration Override") { overrideProperty("DF_VOPERA_FOR_STORIES", { context.config.userInterface.verticalStoryViewer.get() }, { true }, isAppExperiment = true) - overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { context.config.userInterface.disableSpotlight.get() }, + overrideProperty("SPOTLIGHT_5TH_TAB_ENABLED", { + context.config.userInterface.disableSpotlight.get() && + context.feature(Messaging::class).openedConversationUUID == null + }, { false }) overrideProperty("BYPASS_AD_FEATURE_GATE", { context.config.global.blockAds.get() }, From 9fa96125b2e548aecef39a20ab8e7ff0ccd3b497 Mon Sep 17 00:00:00 2001 From: daplugg23 Date: Tue, 14 Apr 2026 19:52:31 -0500 Subject: [PATCH 4/5] fix: resolve dropped friends list by properly handling chunked social snapshots The social snapshot chunking mechanism (introduced in v1.6.2) split the friends list correctly on the BridgeClient, but the BridgeService was discarding subsequent chunks. This updates the Manager to explicitly process and combine all received chunks via the database before updating the AddFriendDialog UI. --- .../kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt | 1 - .../kotlin/me/eternal/purrfectsnap/storage/Messaging.kt | 5 +++++ .../purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt | 6 +----- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt index 9d56d813..83b87b4d 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/bridge/BridgeService.kt @@ -229,7 +229,6 @@ class BridgeService : Service() { pendingSocialSnapshotCallback?.let { callback -> pendingSocialSnapshotCallback = null callback(parsedFriends, parsedGroups) - return } remoteSideContext.database.replaceMessagingData(parsedFriends, parsedGroups) remoteSideContext.database.receiveMessagingDataCallback(parsedFriends, parsedGroups) diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt index 020c4f21..703a548f 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/storage/Messaging.kt @@ -138,6 +138,11 @@ fun AppDatabase.replaceMessagingData( } finally { database.endTransaction() } + + // Notify with the full updated list from the DB + val allFriends = getFriends(descOrder = true) + val allGroups = getGroups() + receiveMessagingDataCallback(allFriends, allGroups) } } diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt index dd7920e4..678d3047 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/manager/pages/social/AddFriendDialog.kt @@ -256,11 +256,7 @@ class AddFriendDialog( ) } - if (context.bridgeService != null) { - context.bridgeService?.requestEphemeralSocialSnapshot(updateSnapshot) - } else { - context.database.receiveMessagingDataCallback = updateSnapshot - } + context.database.receiveMessagingDataCallback = updateSnapshot context.requestSocialSnapshotRefresh() coroutineScope.launch(Dispatchers.IO) { From d61eb68d8b40144d74dd7f17f94ecc2f4cd7ad32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=8B=E1=B4=80=CA=9F=E1=B4=80=E1=B4=85=C9=AA=C9=B4?= <145188583+DarkKnight2122@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:23:19 +0530 Subject: [PATCH 5/5] v1.6.9 --- build.gradle.kts | 4 ++-- changelogs-stable.txt | 11 +++++++++++ gradle.properties | 4 ++-- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 08ac65ef..c43112cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,8 +33,8 @@ tasks.register("getVersion") { } // You can still set these for legacy use by submodules or scripts: -rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.8").get()) -rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("324").get().toInt()) +rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.6.9").get()) +rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("325").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. diff --git a/changelogs-stable.txt b/changelogs-stable.txt index b2457893..05fca07a 100644 --- a/changelogs-stable.txt +++ b/changelogs-stable.txt @@ -1,3 +1,14 @@ +## v1.6.9 +- New: Updated the Stealth mode for better visibility with the chat stealth mode (keeps chats from being read), and snap stealth-mode and full stealth mode toggle (normal stealth-mode). (tq Javalsta) +- Fix: Fixed performance mode profile save/load so Disabled persists correctly and no longer falls back to Max mode on app restart. (tq schrodingerspet) +- Fix: Fixed the resume/reopen UI break when max performance mode is turned on, and other bug fixes. (tq schrodingerspet) +- New: Implemented an Auto Open Stop Button directly within the notification card. +- New: Added a log filter menu in logs page to isolate Auto-Open, Media downloads, friend tracker, and Core logs. +- Fix: Completely rewritten Auto Open Engine to optimize the auto open engine. +- Fix: Fixed Batch Story Download feature not working. +- FIx: Minor bug fixes for media downloader in stories and spotlight. +- Fix: Bug fixes to improve custom emojis stability. + ## v1.6.8 - Fix: Many improvements to the Performance Mode feature(Max, turned on by Default), changes are pretty noticeable: faster loading of chats, long group messages optimizations, many snapmap optimizations - Fix: Crash issues for some devices diff --git a/gradle.properties b/gradle.properties index 839e6e23..5960e246 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,8 +7,8 @@ org.gradle.configuration-cache=true org.gradle.configuration-cache.problems=warn nativeAbis=arm64-v8a -APP_VERSION_NAME=1.6.8 -APP_VERSION_CODE=324 +APP_VERSION_NAME=1.6.9 +APP_VERSION_CODE=325 debug_build_hash=18fe2a814d0e2eb5 psIntegrityPinnedSha256= EXPECTED_CERT_SHA256=0f188cb17d8ea4902ee15ce98f4928ba4226a4790fa3c52f62d776b08e46ca1c