feat: Story Counter , Story Source

This commit is contained in:
RSR/
2026-03-04 07:15:38 +04:00
parent 1b1f68c6cf
commit 5496dc7eb5
7 changed files with 120 additions and 34 deletions

View File

@@ -33,13 +33,15 @@ tasks.register<GetVersionTask>("getVersion") {
}
// You can still set these for legacy use by submodules or scripts:
rootProject.ext.set("appVersionName", providers.gradleProperty("APP_VERSION_NAME").orElse("1.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)}"
)

View File

@@ -1117,6 +1117,10 @@
"name": "عداد القصص",
"description": "يعرض عداداً (مثل 1/10) عند مشاهدة القصص"
},
"story_source_indicator": {
"name": "مؤشر مصدر القصة",
"description": "يعرض أيقونة تشير إلى ما إذا كان السناب تم التقاطه من الكاميرا أو رفعه من المعرض\nيعمل فقط مع قصص الأصدقاء"
},
"old_bitmoji_selfie": {
"name": "سيلفي Bitmoji القديم",
"description": "يعيد صور سيلفي Bitmoji من إصدارات Snapchat القديمة"

View File

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

View File

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

View File

@@ -125,7 +125,6 @@ class FeatureManager(
ConversationToolbox(),
SpotlightCommentsUsername(),
SpotlightCreatorInfo(),
OperaStoryCounter(),
OperaViewerParamsOverride(),
StealthModeIndicator(),
DisablePermissionRequests(),

View File

@@ -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<android.view.View>("story_counter") != null) return@subscribe
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) {
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
}
}
}

View File

@@ -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")