From fb3f1f55c77c9b82c2da561a3daa3f4052656cfc Mon Sep 17 00:00:00 2001 From: RSR/ <52901789+RSR1337@users.noreply.github.com> Date: Wed, 25 Feb 2026 05:08:14 +0400 Subject: [PATCH 1/4] feat:StoryCounter Display the number of snaps in the story --- common/src/main/assets/lang/ar_AE.json | 25 ++-- common/src/main/assets/lang/en_US.json | 57 ++++----- .../common/config/impl/UserInterfaceTweaks.kt | 1 + .../core/features/FeatureManager.kt | 1 + .../features/impl/ui/OperaStoryCounter.kt | 114 ++++++++++++++++++ 5 files changed, 160 insertions(+), 38 deletions(-) create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 4d35b70c..58d37883 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -1113,6 +1113,10 @@ "name": "معلومات سريعة لوسائط أوبرا", "description": "يظهر معلومات مفيدة للوسائط مثل تاريخ الإنشاء في قائمة سياق عارض أوبرا" }, + "story_counter": { + "name": "عداد القصص", + "description": "يعرض عداداً (مثل 1/10) عند مشاهدة القصص" + }, "old_bitmoji_selfie": { "name": "سيلفي Bitmoji القديم", "description": "يعيد صور سيلفي Bitmoji من إصدارات Snapchat القديمة" @@ -2323,16 +2327,16 @@ "custom_android_id": { "null": "استخدام معرف Android الحقيقي" }, - "add_friend_source_spoof": { - "added_by_username": "بواسطة اسم المستخدم", - "added_by_mention": "بواسطة الإشارة (Mention)", - "added_by_group_chat": "بواسطة دردشة المجموعة", - "added_by_qr_code": "بواسطة رمز QR", - "added_by_community": "بواسطة المجتمع", - "added_by_quick_add": "بواسطة الإضافة السريعة (خطر عالي للحظر)", - "added_by_spotlight": "بواسطة منصة الأضواء", - "null": "عدم محاكاة المصدر" - }, + "add_friend_source_spoof": { + "added_by_username": "بواسطة اسم المستخدم", + "added_by_mention": "بواسطة الإشارة (Mention)", + "added_by_group_chat": "بواسطة دردشة المجموعة", + "added_by_qr_code": "بواسطة رمز QR", + "added_by_community": "بواسطة المجتمع", + "added_by_quick_add": "بواسطة الإضافة السريعة (خطر عالي للحظر)", + "added_by_spotlight": "بواسطة منصة الأضواء", + "null": "عدم محاكاة المصدر" + }, "custom_streaks_expiration_format": { "null": "افتراضي النظام" }, @@ -2748,7 +2752,6 @@ "show_chat_edit_history": "عرض تاريخ تعديل الدردشة", "convert_message": "تحويل الرسالة" }, - "chat_wallpaper_downloader": { "download_button": "تنزيل خلفية الدردشة" }, diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 6c657a9f..2c4e9f36 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -312,22 +312,22 @@ "remove_selected_tasks_confirm": "Remove {count} tasks?", "remove_all_tasks_confirm": "Remove all tasks?" }, - "features": { - "disabled": "Disabled", - "export_option": "Export", - "import_option": "Import", - "reset_option": "Reset", - "config_export_success_toast": "Config exported successfully", - "config_import_success_toast": "Config imported successfully", - "config_import_failure_toast": "Failed to import config {error}", - "config_export_failure_toast": "Failed to export config {error}", - "saved_config_snackbar": "Config saved", - "older_required": "This feature requires Snapchat v{version} or older to work correctly", - "newer_required": "This feature requires Snapchat v{version} or newer to work correctly", - "search_button": "Search", - "clear_history": "Clear search history", - "subtitle": "Search and manage features" - }, + "features": { + "disabled": "Disabled", + "export_option": "Export", + "import_option": "Import", + "reset_option": "Reset", + "config_export_success_toast": "Config exported successfully", + "config_import_success_toast": "Config imported successfully", + "config_import_failure_toast": "Failed to import config {error}", + "config_export_failure_toast": "Failed to export config {error}", + "saved_config_snackbar": "Config saved", + "older_required": "This feature requires Snapchat v{version} or older to work correctly", + "newer_required": "This feature requires Snapchat v{version} or newer to work correctly", + "search_button": "Search", + "clear_history": "Clear search history", + "subtitle": "Search and manage features" + }, "bypass_status": { "active": "PurrAura Active", "inactive": "PurrAura Inactive" @@ -1113,6 +1113,10 @@ "name": "Opera Media Quick Info", "description": "Shows useful information of media such as creation date in opera viewer context menu" }, + "story_counter": { + "name": "Story Counter", + "description": "Shows a counter (e.g. 1/10) when viewing stories" + }, "old_bitmoji_selfie": { "name": "Old Bitmoji Selfie", "description": "Brings back the Bitmoji selfies from older Snapchat versions" @@ -2323,16 +2327,16 @@ "custom_android_id": { "null": "Use real Android ID" }, - "add_friend_source_spoof": { - "added_by_username": "By Username", - "added_by_mention": "By Mention", - "added_by_group_chat": "By Group Chat", - "added_by_qr_code": "By QR Code", - "added_by_community": "By Community", - "added_by_quick_add": "By Quick Add (high risk of being banned)", - "added_by_spotlight": "By Spotlight", - "null": "Don't spoof source" - }, + "add_friend_source_spoof": { + "added_by_username": "By Username", + "added_by_mention": "By Mention", + "added_by_group_chat": "By Group Chat", + "added_by_qr_code": "By QR Code", + "added_by_community": "By Community", + "added_by_quick_add": "By Quick Add (high risk of being banned)", + "added_by_spotlight": "By Spotlight", + "null": "Don't spoof source" + }, "custom_streaks_expiration_format": { "null": "System Default" }, @@ -2747,7 +2751,6 @@ "show_chat_edit_history": "Show Chat Edit History", "convert_message": "Convert Message" }, - "chat_wallpaper_downloader": { "download_button": "Download Chat Wallpaper" }, diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index 57d50a31..f2295f1c 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -50,6 +50,7 @@ class UserInterfaceTweaks : ConfigContainer() { "hide_map_reactions", ) { requireRestart(); versionCheck = RES_OBF_VERSION_CHECK } val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() } + val storyCounter = boolean("story_counter") { requireRestart() } val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 3037648d..c7ec6c39 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -87,6 +87,7 @@ class FeatureManager( AutoSave(), AutoReply(), UITweaks(), + OperaStoryCounter(), ConfigurationOverride(), COFOverride(), UnsaveableMessages(), diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt new file mode 100644 index 00000000..871d2f86 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt @@ -0,0 +1,114 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import android.view.Gravity +import android.widget.FrameLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.eternal.purrfectsnap.common.ui.createComposeView +import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent +import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.ui.children +import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.hook +import me.eternal.purrfectsnap.core.util.ktx.getObjectField +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.Layer +import me.eternal.purrfectsnap.core.wrapper.impl.media.opera.ParamMap +import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper + +class OperaStoryCounter : Feature("OperaStoryCounter") { + private val counterState = mutableStateOf("") + + override fun init() { + if (!this@OperaStoryCounter.context.config.userInterface.storyCounter.get()) return + + this@OperaStoryCounter.context.event.subscribe(AddViewEvent::class) { event -> + if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) { + val viewGroup = event.view as FrameLayout + + // Strict check: don't add if already exists in the entire parent hierarchy + if (event.parent.findViewWithTag("story_counter") != null) return@subscribe + + if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe + + val composeView = createComposeView(viewGroup.context) { + if (counterState.value.isNotEmpty()) { + androidx.compose.material3.Surface( + shape = androidx.compose.foundation.shape.CircleShape, + color = Color(0x4C000000), // 30% opacity black + ) { + Text( + text = counterState.value, + color = Color.White, + fontSize = 11.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, + modifier = Modifier + .padding(horizontal = 10.dp, vertical = 3.dp) + ) + } + } + }.apply { + tag = "story_counter" + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ).apply { + gravity = Gravity.TOP or Gravity.END + topMargin = this@OperaStoryCounter.context.userInterface.dpToPx(55) + marginEnd = this@OperaStoryCounter.context.userInterface.dpToPx(10) + } + } + viewGroup.addView(composeView) + } + } + + onNextActivityCreate { + this@OperaStoryCounter.context.mappings.useMapper(OperaPageViewControllerMapper::class) { + arrayOf(onDisplayStateChange, onDisplayStateChangeGesture).forEach { methodName -> + 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@hook + } + + val operaLayerList = (param.thisObject() as Any).getObjectField(layerListField.get()!!) as ArrayList<*> + val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap + val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString() + + if (snapSource == "SINGLE_SNAP_STORY" || snapSource == "CHAT" || mediaParamMap.containsKey("CHAT_ID") || mediaParamMap.containsKey("CONVERSATION_ID")) { + this@OperaStoryCounter.context.runOnUiThread { + counterState.value = "" + } + return@hook + } + + val currentIndex = mediaParamMap["snap_index_in_story"]?.toString()?.toIntOrNull() + ?: mediaParamMap["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() + val totalCount = mediaParamMap["snap_story_length"]?.toString()?.toIntOrNull() + ?: mediaParamMap["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() + + if (currentIndex != null && totalCount != null && totalCount > 0) { + this@OperaStoryCounter.context.runOnUiThread { + counterState.value = "${currentIndex + 1} / $totalCount" + } + } else { + this@OperaStoryCounter.context.runOnUiThread { + counterState.value = "" + } + } + } + } + } + } + } +} From 1b1f68c6cf25325f313060a0b5f3c49fa52085b9 Mon Sep 17 00:00:00 2001 From: RSR/ <52901789+RSR1337@users.noreply.github.com> Date: Thu, 26 Feb 2026 06:44:29 +0400 Subject: [PATCH 2/4] : ) --- .../me/eternal/purrfectsnap/core/features/FeatureManager.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index c7ec6c39..7d961af6 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -124,6 +124,8 @@ class FeatureManager( PreventForcedLogout(), ConversationToolbox(), SpotlightCommentsUsername(), + SpotlightCreatorInfo(), + OperaStoryCounter(), OperaViewerParamsOverride(), StealthModeIndicator(), DisablePermissionRequests(), From 5496dc7eb51fa96e6dfdb02eadc934d1f774f646 Mon Sep 17 00:00:00 2001 From: RSR/ <52901789+RSR1337@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:15:38 +0400 Subject: [PATCH 3/4] feat: Story Counter , Story Source --- build.gradle.kts | 12 +- common/src/main/assets/lang/ar_AE.json | 4 + common/src/main/assets/lang/en_US.json | 4 + .../common/config/impl/UserInterfaceTweaks.kt | 1 + .../core/features/FeatureManager.kt | 1 - .../features/impl/ui/OperaStoryCounter.kt | 128 ++++++++++++++---- native/build.gradle.kts | 4 + 7 files changed, 120 insertions(+), 34 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4bdb5176..5c1f0032 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,13 +33,15 @@ 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.2.6").get()) -rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("252").get().toInt()) +rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.0").get()) +rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("270").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. rootProject.ext.set( "buildHash", - properties["debug_build_hash"] - ?: java.security.SecureRandom() + (properties["debug_build_hash"] as String?) + ?: "${rootProject.ext["appVersionCode"]}_${java.security.SecureRandom() .nextLong(Long.MAX_VALUE / 1000L, Long.MAX_VALUE) - .toString(16) + .toString(16)}" ) diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 58d37883..b5b7e8db 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -1117,6 +1117,10 @@ "name": "عداد القصص", "description": "يعرض عداداً (مثل 1/10) عند مشاهدة القصص" }, + "story_source_indicator": { + "name": "مؤشر مصدر القصة", + "description": "يعرض أيقونة تشير إلى ما إذا كان السناب تم التقاطه من الكاميرا أو رفعه من المعرض\nيعمل فقط مع قصص الأصدقاء" + }, "old_bitmoji_selfie": { "name": "سيلفي Bitmoji القديم", "description": "يعيد صور سيلفي Bitmoji من إصدارات Snapchat القديمة" diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 2c4e9f36..9df764a5 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -1117,6 +1117,10 @@ "name": "Story Counter", "description": "Shows a counter (e.g. 1/10) when viewing stories" }, + "story_source_indicator": { + "name": "Story Source Indicator", + "description": "Shows an icon indicating whether the snap was taken from the camera or uploaded from the gallery\nOnly works with friend stories" + }, "old_bitmoji_selfie": { "name": "Old Bitmoji Selfie", "description": "Brings back the Bitmoji selfies from older Snapchat versions" diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index f2295f1c..263fb8b8 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -51,6 +51,7 @@ class UserInterfaceTweaks : ConfigContainer() { ) { requireRestart(); versionCheck = RES_OBF_VERSION_CHECK } val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() } val storyCounter = boolean("story_counter") { requireRestart() } + val storySourceIndicator = boolean("story_source_indicator") { requireRestart() } val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 7d961af6..81db8a99 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -125,7 +125,6 @@ class FeatureManager( ConversationToolbox(), SpotlightCommentsUsername(), SpotlightCreatorInfo(), - OperaStoryCounter(), OperaViewerParamsOverride(), StealthModeIndicator(), DisablePermissionRequests(), diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt index 871d2f86..a442463a 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt @@ -3,12 +3,23 @@ package me.eternal.purrfectsnap.core.features.impl.ui import android.view.Gravity import android.widget.FrameLayout import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CameraAlt +import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.eternal.purrfectsnap.common.ui.createComposeView @@ -24,33 +35,76 @@ import me.eternal.purrfectsnap.mapper.impl.OperaPageViewControllerMapper class OperaStoryCounter : Feature("OperaStoryCounter") { private val counterState = mutableStateOf("") + // "CAMERA" or "GALLERY" or "" + private val sourceState = mutableStateOf("") override fun init() { - if (!this@OperaStoryCounter.context.config.userInterface.storyCounter.get()) return + val showCounter = this@OperaStoryCounter.context.config.userInterface.storyCounter.get() + val showSourceIndicator = this@OperaStoryCounter.context.config.userInterface.storySourceIndicator.get() + + if (!showCounter && !showSourceIndicator) return this@OperaStoryCounter.context.event.subscribe(AddViewEvent::class) { event -> if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) { val viewGroup = event.view as FrameLayout - - // Strict check: don't add if already exists in the entire parent hierarchy - if (event.parent.findViewWithTag("story_counter") != null) return@subscribe + + if (viewGroup.findViewWithTag("story_counter") != null || + event.parent.findViewWithTag("story_counter") != null) return@subscribe if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe val composeView = createComposeView(viewGroup.context) { - if (counterState.value.isNotEmpty()) { - androidx.compose.material3.Surface( - shape = androidx.compose.foundation.shape.CircleShape, - color = Color(0x4C000000), // 30% opacity black + val counterText = counterState.value + val source = sourceState.value + val hasCounter = showCounter && counterText.isNotEmpty() + val hasSource = showSourceIndicator && source.isNotEmpty() + + if (hasCounter || hasSource) { + Box( + modifier = Modifier + .background( + color = Color(0x4C000000), + shape = androidx.compose.foundation.shape.CircleShape + ) ) { - Text( - text = counterState.value, - color = Color.White, - fontSize = 11.sp, - fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, - modifier = Modifier - .padding(horizontal = 10.dp, vertical = 3.dp) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) + ) { + // Counter section + if (hasCounter) { + Text( + text = counterText, + color = Color.White, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + } + + // Divider between counter and source icon + if (hasCounter && hasSource) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .width(1.dp) + .height(14.dp) + .background(Color.White.copy(alpha = 0.5f)) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + + // Source icon section + if (hasSource) { + val icon = if (source == "CAMERA") Icons.Outlined.CameraAlt else Icons.Outlined.PhotoLibrary + val description = if (source == "CAMERA") "Camera" else "Gallery" + Icon( + imageVector = icon, + contentDescription = description, + tint = Color.White, + modifier = Modifier.size(14.dp) + ) + } + } } } }.apply { @@ -60,7 +114,7 @@ class OperaStoryCounter : Feature("OperaStoryCounter") { FrameLayout.LayoutParams.WRAP_CONTENT ).apply { gravity = Gravity.TOP or Gravity.END - topMargin = this@OperaStoryCounter.context.userInterface.dpToPx(55) + topMargin = this@OperaStoryCounter.context.userInterface.dpToPx(50) marginEnd = this@OperaStoryCounter.context.userInterface.dpToPx(10) } } @@ -85,26 +139,44 @@ class OperaStoryCounter : Feature("OperaStoryCounter") { val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString() - if (snapSource == "SINGLE_SNAP_STORY" || snapSource == "CHAT" || mediaParamMap.containsKey("CHAT_ID") || mediaParamMap.containsKey("CONVERSATION_ID")) { + // Don't show counter in conversation chats or groups + if (mediaParamMap.containsKey("MESSAGE_ID")) { this@OperaStoryCounter.context.runOnUiThread { counterState.value = "" + sourceState.value = "" } return@hook } - val currentIndex = mediaParamMap["snap_index_in_story"]?.toString()?.toIntOrNull() + // Don't show counter on Spotlight (single snap, no story context) + if (snapSource == "SINGLE_SNAP_STORY") { + this@OperaStoryCounter.context.runOnUiThread { + counterState.value = "" + sourceState.value = "" + } + return@hook + } + + // Extract counter info + val currentIndex = mediaParamMap["snap_index_in_story"]?.toString()?.toIntOrNull() ?: mediaParamMap["SNAP_POSITION_IN_STORY"]?.toString()?.toIntOrNull() val totalCount = mediaParamMap["snap_story_length"]?.toString()?.toIntOrNull() ?: mediaParamMap["NUM_SNAPS_IN_STORY"]?.toString()?.toIntOrNull() - if (currentIndex != null && totalCount != null && totalCount > 0) { - this@OperaStoryCounter.context.runOnUiThread { - counterState.value = "${currentIndex + 1} / $totalCount" - } - } else { - this@OperaStoryCounter.context.runOnUiThread { - counterState.value = "" - } + // Extract media origin from PLAYABLE_STORY_SNAP_RECORD + var mediaOrigin = "" + if (showSourceIndicator) { + val snapRecord = mediaParamMap["PLAYABLE_STORY_SNAP_RECORD"]?.toString() ?: "" + mediaOrigin = if (snapRecord.contains("mediaOrigins=")) { + if (snapRecord.contains("mediaOrigins=[CAMERA]")) "CAMERA" else "GALLERY" + } else "" + } + + this@OperaStoryCounter.context.runOnUiThread { + counterState.value = if (showCounter && currentIndex != null && totalCount != null && totalCount > 0) { + "${currentIndex + 1} / $totalCount" + } else "" + sourceState.value = mediaOrigin } } } diff --git a/native/build.gradle.kts b/native/build.gradle.kts index 47825965..c5c6cf0d 100644 --- a/native/build.gradle.kts +++ b/native/build.gradle.kts @@ -283,6 +283,10 @@ val syncTasks = cargoTargets.mapIndexed { index, target -> } } +// Ensure checksums dir exists at configuration time so Gradle 9 input validation passes +// (it is populated by syncTasks' doLast) +layout.buildDirectory.dir("checksums").get().asFile.mkdirs() + val generateChecksumsFile = tasks.register("generateChecksumsFile") { dependsOn(syncTasks) val generatedDir = layout.buildDirectory.dir("generated/source/checksums/kotlin") From 87ca0eaa824ebbf430ce82fd4c2f5b1193c3cb63 Mon Sep 17 00:00:00 2001 From: RSR/ <52901789+RSR1337@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:28:26 +0400 Subject: [PATCH 4/4] fix: Media Quality --- .../features/impl/ConfigurationOverride.kt | 33 ++++++++ .../impl/global/MediaUploadQualityOverride.kt | 84 +++++++++++++------ .../impl/MediaQualityLevelProviderMapper.kt | 51 ++++++++++- 3 files changed, 140 insertions(+), 28 deletions(-) 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 cb507aa8..b7996471 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 @@ -48,6 +48,39 @@ class ConfigurationOverride : Feature("Configuration Override") { overrideProperty("TRANSCODING_MAX_QUALITY", { context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() }, { true }, isAppExperiment = true) + run { + val isForceQuality = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get() } + val level7Value = { _: ConfigKeyInfo -> 700 } + arrayOf( + "MY_STORY_UPLOAD_QUALITY_LEVEL", + "PUBLIC_STORY_UPLOAD_QUALITY_LEVEL", + "GROUP_STORY_UPLOAD_QUALITY_LEVEL", + "SPOTLIGHT_UPLOAD_QUALITY_LEVEL", + "MEDIA_UPLOAD_QUALITY_LEVEL", + "MESSAGING_MEDIA_UPLOAD_QUALITY_LEVEL", + "IMAGE_UPLOAD_QUALITY_LEVEL", + "IMAGE_QUALITY_LEVEL_FOR_PRE_UPLOAD", + "IMAGE_QUALITY_LEVEL_FOR_PUBLIC_POSTING", + "IMAGE_QUALITY_LEVEL_FOR_PRIVATE_POSTING", + "RETRANSCODE_UPLOAD_QUALITY_LEVEL", + "MEMORIES_BACKUP_MEDIA_LEVEL", + "MEMORIES_BACKUP_MEDIA_LEVEL_HIGH_QUALITY", + "MEDIA_EXPORT_QUALITY_LEVEL" + ).forEach { key -> + overrideProperty(key, isForceQuality, level7Value) + } + overrideProperty("ENABLE_HIGH_QUALITY_MEMORIES_BACKUP", + isForceQuality, { true }) + overrideProperty("MEDIA_QUALITY_LEVEL_DOWNGRADING_PERCENTAGE", + isForceQuality, { 0.0f }) + } + + run { + val isDisableCompression = { _: ConfigKeyInfo -> context.config.global.mediaUploadQualityConfig.disableImageCompression.get() } + overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY", isDisableCompression, { 100 }) + overrideProperty("LIBJPEG_IMAGE_ENCODING_QUALITY_V2", isDisableCompression, { 100 }) + } + overrideProperty("CAMERA_ME_ENABLE_HEVC_RECORDING", { context.config.camera.hevcRecording.get() }, { true }) overrideProperty("MEDIA_RECORDER_MAX_QUALITY_LEVEL", { context.config.camera.forceCameraSourceEncoding.get() }, diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/MediaUploadQualityOverride.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/MediaUploadQualityOverride.kt index 4a2a4ab1..204a5a4d 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/MediaUploadQualityOverride.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/global/MediaUploadQualityOverride.kt @@ -11,22 +11,34 @@ class MediaUploadQualityOverride : Feature("Media Upload Quality Override") { override fun init() { if (context.config.global.mediaUploadQualityConfig.forceVideoUploadSourceQuality.get()) { context.mappings.useMapper(MediaQualityLevelProviderMapper::class) { - mediaQualityLevelProvider.getAsClass()?.hook( - mediaQualityLevelProviderMethod.getAsString()!!, - HookStage.AFTER + val providerClass = mediaQualityLevelProvider.getAsClass() + val providerMethodName = mediaQualityLevelProviderMethod.getAsString() + + if (providerClass == null || providerMethodName == null) { + context.log.warn("MediaQualityLevelProvider mapping failed - provider class or method not found") + return@useMapper + } + + providerClass.hook( + providerMethodName, + HookStage.BEFORE ) { param -> - val method = param.method() as Method - val enumClass = method.returnType - val values = enumClass.enumConstants ?: return@hook - val nameMethod = runCatching { enumClass.getMethod("name") }.getOrNull() - val maxIndex = values.indexOfFirst { nameMethod?.invoke(it)?.toString() == "LEVEL_MAX" } - val targetIndex = when { - maxIndex > 0 -> maxIndex - 1 - values.size > 1 -> values.lastIndex - 1 - else -> return@hook + try { + val method = param.method() as Method + val returnType = method.returnType + + val levelMax = returnType.enumConstants?.firstOrNull { + it.toString() == "LEVEL_MAX" + } + + if (levelMax != null) { + param.setArg(1, levelMax) + } else { + context.log.warn("LEVEL_MAX enum constant not found. Available: ${returnType.enumConstants?.joinToString()}") + } + } catch (e: Exception) { + context.log.error("Failed to override video quality", e) } - val target = values[targetIndex] - if (param.getResult() != target) param.setResult(target) } } } @@ -36,23 +48,41 @@ class MediaUploadQualityOverride : Feature("Media Upload Quality Override") { if (imageUploadFormat != null || disableImageCompression) { Bitmap::class.java.hook("compress", HookStage.BEFORE) { param -> - if (param.arg(1) == 0) return@hook - if (param.arg(0) == Bitmap.CompressFormat.JPEG) { - @Suppress("DEPRECATION") - param.setArg(0, when (imageUploadFormat) { - "png" -> Bitmap.CompressFormat.PNG - "webp" -> Bitmap.CompressFormat.WEBP - "jpeg" -> Bitmap.CompressFormat.JPEG - else -> Bitmap.CompressFormat.JPEG - }) - if (disableImageCompression) { - param.setArg(1, 100) + try { + val quality = param.arg(1) + val currentFormat = param.arg(0) + + if (quality == 0) return@hook + + if (currentFormat == Bitmap.CompressFormat.JPEG || + currentFormat == Bitmap.CompressFormat.PNG || + currentFormat == Bitmap.CompressFormat.WEBP) { + + @Suppress("DEPRECATION") + val newFormat = when (imageUploadFormat) { + "png" -> Bitmap.CompressFormat.PNG + "webp" -> Bitmap.CompressFormat.WEBP + "jpeg" -> Bitmap.CompressFormat.JPEG + else -> currentFormat as Bitmap.CompressFormat + } + + param.setArg(0, newFormat) + + if (disableImageCompression) { + param.setArg(1, 100) + } } + } catch (e: Exception) { + context.log.error("Failed to override image compression", e) } } - findClass("com.snap.camera.jni.SnapImageTranscoder").hook("nativeEncodeBitmapToJpeg", HookStage.BEFORE) { - it.setResult(ByteArray(0)) + try { + findClass("com.snap.camera.jni.SnapImageTranscoder").hook("nativeEncodeBitmapToJpeg", HookStage.BEFORE) { + it.setResult(ByteArray(0)) + } + } catch (e: Exception) { + context.log.warn("Failed to hook SnapImageTranscoder - this is expected on some Snapchat versions") } } } diff --git a/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/impl/MediaQualityLevelProviderMapper.kt b/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/impl/MediaQualityLevelProviderMapper.kt index 33a95e43..40944442 100644 --- a/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/impl/MediaQualityLevelProviderMapper.kt +++ b/mapper/src/main/kotlin/me/eternal/purrfectsnap/mapper/impl/MediaQualityLevelProviderMapper.kt @@ -2,6 +2,7 @@ package me.eternal.purrfectsnap.mapper.impl import com.android.tools.smali.dexlib2.AccessFlags import me.eternal.purrfectsnap.mapper.AbstractClassMapper +import me.eternal.purrfectsnap.mapper.ext.findConstString import me.eternal.purrfectsnap.mapper.ext.getClassName import me.eternal.purrfectsnap.mapper.ext.hasStaticConstructorString import me.eternal.purrfectsnap.mapper.ext.isAbstract @@ -32,12 +33,60 @@ class MediaQualityLevelProviderMapper : AbstractClassMapper("MediaQualityLevelPr if (!clazz.isAbstract()) continue if (clazz.fields.none { it.accessFlags and AccessFlags.TRANSIENT.value != 0 }) continue - clazz.methods.firstOrNull { it.returnType == "L$enumQualityLevel;" }?.let { + clazz.methods.firstOrNull { method -> + method.returnType == "L$enumQualityLevel;" + && method.parameters.size == 2 + && method.parameters.last().type == "L$enumQualityLevel;" + }?.let { mediaQualityLevelProvider.set(clazz.getClassName()) mediaQualityLevelProviderMethod.set(it.name) return@mapper } } } + + mapper { + if (enumQualityLevel == null || mediaQualityLevelProvider.get() != null) return@mapper + + for (clazz in classes) { + if (clazz.methods.none { it.implementation?.findConstString("video-transcoding-level-", contains = true) == true }) continue + + clazz.methods.firstOrNull { method -> + method.returnType == "L$enumQualityLevel;" + && method.parameters.size == 2 + && method.parameters.last().type == "L$enumQualityLevel;" + }?.let { + mediaQualityLevelProvider.set(clazz.getClassName()) + mediaQualityLevelProviderMethod.set(it.name) + return@mapper + } + } + } + + mapper { + if (enumQualityLevel == null || mediaQualityLevelProvider.get() != null) return@mapper + + for (clazz in classes) { + val hasTranscodingConfig = clazz.methods.any { method -> + method.implementation?.findConstString("video-transcoding-level-1", contains = true) == true + } + if (!hasTranscodingConfig) continue + + val hasImageTranscodingConfig = clazz.methods.any { method -> + method.implementation?.findConstString("image_transcoding_level_", contains = true) == true + } + if (!hasImageTranscodingConfig) continue + + clazz.methods.firstOrNull { method -> + method.returnType == "L$enumQualityLevel;" + && method.parameters.size == 2 + && method.parameters.last().type == "L$enumQualityLevel;" + }?.let { qualityMethod -> + mediaQualityLevelProvider.set(clazz.getClassName()) + mediaQualityLevelProviderMethod.set(qualityMethod.name) + return@mapper + } + } + } } }