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