many new features & fixes by RSR

- Story counter & Story Source
- fix: Media Quality Override
This commit is contained in:
ΞTΞRNAL
2026-03-04 09:13:04 +05:30
committed by GitHub
10 changed files with 364 additions and 43 deletions

View File

@@ -36,10 +36,12 @@ tasks.register<GetVersionTask>("getVersion") {
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.3.2").get())
rootProject.ext.set("appVersionCode", providers.gradleProperty("APP_VERSION_CODE").orElse("272").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)}"
)

View File

@@ -1099,6 +1099,14 @@
"name": "معلومات الوسائط السريعة في أوبرا",
"description": "يعرض معلومات مفيدة عن الوسائط مثل تاريخ الإنشاء في قائمة سياق عارض أوبرا"
},
"story_counter": {
"name": "عداد القصص",
"description": "يعرض عداداً (مثل 1/10) عند مشاهدة القصص"
},
"story_source_indicator": {
"name": "مؤشر مصدر القصة",
"description": "يعرض أيقونة تشير إلى ما إذا كان السناب تم التقاطه من الكاميرا أو رفعه من المعرض\nيعمل فقط مع قصص الأصدقاء"
},
"old_bitmoji_selfie": {
"name": "سيلفي Bitmoji القديم",
"description": "يعيد سيلفي Bitmoji من إصدارات Snapchat القديمة"
@@ -2767,7 +2775,6 @@
"show_chat_edit_history": "عرض سجل تحرير الدردشة",
"convert_message": "تحويل الرسالة"
},
"chat_wallpaper_downloader": {
"download_button": "تنزيل خلفية الدردشة"
},

View File

@@ -1099,6 +1099,14 @@
"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"
},
"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"
@@ -2339,16 +2347,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"
},
@@ -2766,7 +2774,6 @@
"show_chat_edit_history": "Show Chat Edit History",
"convert_message": "Convert Message"
},
"chat_wallpaper_downloader": {
"download_button": "Download Chat Wallpaper"
},

View File

@@ -50,6 +50,8 @@ 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 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() }

View File

@@ -87,6 +87,7 @@ class FeatureManager(
AutoSave(),
AutoReply(),
UITweaks(),
OperaStoryCounter(),
ConfigurationOverride(),
COFOverride(),
UnsaveableMessages(),

View File

@@ -47,6 +47,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() },

View File

@@ -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<Int>(1) == 0) return@hook
if (param.arg<Any>(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<Int>(1)
val currentFormat = param.arg<Any>(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")
}
}
}

View File

@@ -0,0 +1,186 @@
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.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
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("")
// "CAMERA" or "GALLERY" or ""
private val sourceState = mutableStateOf("")
override fun init() {
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
if (viewGroup.findViewWithTag<android.view.View>("story_counter") != null ||
event.parent.findViewWithTag<android.view.View>("story_counter") != null) return@subscribe
if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe
val composeView = createComposeView(viewGroup.context) {
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
)
) {
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 {
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(50)
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()
// Don't show counter in conversation chats or groups
if (mediaParamMap.containsKey("MESSAGE_ID")) {
this@OperaStoryCounter.context.runOnUiThread {
counterState.value = ""
sourceState.value = ""
}
return@hook
}
// 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()
// 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
}
}
}
}
}
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -294,6 +294,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")