From ed66ae2998449564ab4613dd366921952fc1a23a Mon Sep 17 00:00:00 2001 From: RSR/ <52901789+RSR1337@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:29:46 +0400 Subject: [PATCH] feat: Spoof Followers Count by RSR (#142) --- .../purrfectsnap/ui/util/AlertDialogs.kt | 24 +++- common/src/main/assets/lang/ar_AE.json | 17 ++- common/src/main/assets/lang/en_US.json | 17 ++- .../common/config/ConfigObjects.kt | 1 + .../common/config/impl/UserInterfaceTweaks.kt | 12 +- .../features/impl/ui/FakeFollowersCount.kt | 130 ++++++++++++++++++ 6 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FakeFollowersCount.kt diff --git a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt index b46892e9..bc242878 100644 --- a/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt +++ b/app/src/main/kotlin/me/eternal/purrfectsnap/ui/util/AlertDialogs.kt @@ -431,9 +431,10 @@ class AlertDialogs( DefaultDialogCard { var fieldValue by remember { mutableStateOf(property.value.get().toString().let { + val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it TextFieldValue( - text = it, - selection = TextRange(it.length) + text = t, + selection = TextRange(t.length) ) }) } @@ -447,10 +448,21 @@ class AlertDialogs( } .focusRequester(focusRequester), value = fieldValue, - onValueChange = { fieldValue = it }, - keyboardOptions = when (property.key.dataType.type) { - DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number) - DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal) + onValueChange = { newVal -> + fieldValue = if (property.key.params.digitsOnlyInput) { + val filtered = newVal.text.filter { ch -> ch.isDigit() } + if (newVal.text != filtered) { + Toast.makeText(context, translation["manager.sections.features.digits_only_toast"], Toast.LENGTH_SHORT).show() + } + newVal.copy(text = filtered) + } else { + newVal + } + }, + keyboardOptions = when { + property.key.params.digitsOnlyInput -> KeyboardOptions(keyboardType = KeyboardType.Number) + property.key.dataType.type == DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number) + property.key.dataType.type == DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal) else -> KeyboardOptions(keyboardType = KeyboardType.Text) }, singleLine = true, diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 37e6fdb5..3b588ed6 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -359,7 +359,8 @@ "search_button": "بحث", "search_results_count": "{count} رسالة", "clear_history": "مسح سجل البحث", - "subtitle": "البحث وإدارة الميزات" + "subtitle": "البحث وإدارة الميزات", + "digits_only_toast": "يُسمح بالأرقام فقط." }, "bypass_status": { "active": "PurrAura نشط", @@ -1169,6 +1170,16 @@ "description": "تعيين نقاط السناب شات الوهمية (أقصى عدد هو 9,999,999)" } } + }, + "spoof_followers_count": { + "name": "تزييف عدد المتابعين", + "description": "يعرض عدد متابعين مزيّفاً على ملفك (محلي فقط).", + "properties": { + "custom_followers_count": { + "name": "عدد المتابعين المخصص", + "description": "الرقم المعروض (أرقام فقط)." + } + } } } }, @@ -2569,9 +2580,11 @@ "encryption_indicator": "يضيف أيقونة \ud83d\udd12 بجوار الرسائل التي تم إرسالها إليك فقط", "platform_indicator": "يضيف أيقونة المنصة التي تم إرسال الوسيط منها (مثل Android، iOS، Web)", "location_indicator": "يضيف أيقونة \ud83d\udccd للـ snaps عندما يتم إرسالها مع تمكين الموقع", + "live_camera_indicator": "إضافة رمز \uD83D\uDCF7 عندما يُلتقط السناب بكاميرا التطبيق مباشرة (تصوير حي)", + "external_media_indicator": "إضافة رمز \uD83D\uDDBC\uFE0F لوسائط معرض الصور أو السنابات المُرسَلة من المعرض", "ovf_editor_indicator": "يشير إلى ما إذا كان snap قد تم إرساله باستخدام محرر OVF", "director_mode_indicator": "يضيف أيقونة \u270f\ufe0f للـ snaps عندما يتم إرسالها باستخدام وضع المخرج، والذي يمكن استخدامه لإرسال صور المعرض كـ snaps", - "memories_indicator": "إضافة رمز \uD83D\uDCD6 للسنابات التي تم إعادة إرسالها من الذكريات بدلاً من التقاطها بالكاميرا الحية" + "memories_indicator": "إضافة أيقونة كتاب مفتوح (\uD83D\uDCD6) عند إعادة إرسال سناب من الذكريات وليس بالتقاط حي بالكاميرا" }, "auto_mark_as_read": { "conversation_read": "وضع علامة مقروء على المحادثة عند إرسال رسالة", diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index 0a72f02a..9bd059a2 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -391,7 +391,8 @@ "search_button": "Search", "search_results_count": "{count} messages", "clear_history": "Clear search history", - "subtitle": "Explore and manage premium features" + "subtitle": "Explore and manage premium features", + "digits_only_toast": "Only numbers are allowed." }, "bypass_status": { "active": "PurrAura Active", @@ -1277,6 +1278,16 @@ "description": "The custom Snap Score you want to display (max 9,999,999)" } } + }, + "spoof_followers_count": { + "name": "Spoof Followers Count", + "description": "Spoof your follower count on your profile (local only).", + "properties": { + "custom_followers_count": { + "name": "Custom Followers Count", + "description": "Number to show (digits only)." + } + } } } }, @@ -3256,9 +3267,11 @@ "encryption_indicator": "Adds a \ud83d\udd12 icon next to messages that have been sent only to you", "platform_indicator": "Adds the platform icon from which a media was sent (e.g. Android, iOS, Web)", "location_indicator": "Adds a \ud83d\udccd icon to snaps when they have been sent with location enabled", + "live_camera_indicator": "Adds a \ud83d\dcf7 icon when the snap was captured with the in-app camera (live)", + "external_media_indicator": "Adds a \ud83d\uddbc\ufe0f icon for camera roll / gallery media and snaps sent from the gallery", "ovf_editor_indicator": "Indicates if a snap has been sent using OVF Editor", "director_mode_indicator": "Adds a \u270f\ufe0f icon to snaps when they have been sent using Director Mode, which can be used to send gallery images as snaps", - "memories_indicator": "Adds a \ud83d\udcd6 icon to snaps that were re-sent from Memories instead of being captured with the live camera" + "memories_indicator": "Adds an open-book style icon (\ud83d\udcd6) when a snap was re-sent from Memories (not live capture)" }, "auto_mark_as_read": { "conversation_read": "Mark conversation as read when sending a message", diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigObjects.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigObjects.kt index 3071bd38..8fd5732a 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigObjects.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/ConfigObjects.kt @@ -81,6 +81,7 @@ class ConfigParams( var inputCheck: ((String) -> Boolean)? = { true }, var filenameFilter: ((String) -> Boolean)? = null, var versionCheck: VersionCheck? = null, + var digitsOnlyInput: Boolean = false, ) { val notices get() = _notices?.let { FeatureNotice.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList() val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList() 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 550652b8..fe82f3bb 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 @@ -57,7 +57,7 @@ class UserInterfaceTweaks : ConfigContainer() { val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() } val disableSpotlight = boolean("disable_spotlight") { requireRestart() } val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() } - val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator") { requireRestart() } + val messageIndicators = multiple("message_indicators", "encryption_indicator", "platform_indicator", "location_indicator", "live_camera_indicator", "external_media_indicator", "ovf_editor_indicator", "director_mode_indicator", "memories_indicator") { requireRestart() } val stealthModeIndicator = boolean("stealth_mode_indicator") { requireRestart() } val editTextOverride = multiple("edit_text_override", "multi_line_chat_input", "bypass_text_input_limit") { requireRestart(); addNotices(FeatureNotice.BAN_RISK, FeatureNotice.INTERNAL_BEHAVIOR) @@ -77,4 +77,14 @@ class UserInterfaceTweaks : ConfigContainer() { } val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() } + + inner class SpoofFollowersCount : ConfigContainer(hasGlobalState = true) { + val customFollowersCount = string("custom_followers_count") { + requireRestart() + digitsOnlyInput = true + inputCheck = { input -> input.isEmpty() || input.all { it.isDigit() } } + } + } + + val spoofFollowersCount = container("spoof_followers_count", SpoofFollowersCount()) { requireRestart() } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FakeFollowersCount.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FakeFollowersCount.kt new file mode 100644 index 00000000..ed050060 --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FakeFollowersCount.kt @@ -0,0 +1,130 @@ +package me.eternal.purrfectsnap.core.features.impl.ui + +import android.widget.TextView +import me.eternal.purrfectsnap.core.features.Feature +import me.eternal.purrfectsnap.core.util.hook.HookStage +import me.eternal.purrfectsnap.core.util.hook.hook +import java.math.BigDecimal +import java.math.BigInteger +import java.math.MathContext +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + +class FakeFollowersCount : Feature("Fake Followers Count") { + + override fun init() { + if (context.config.userInterface.spoofFollowersCount.globalState != true) return + val raw = context.config.userInterface.spoofFollowersCount.customFollowersCount.getNullable()?.trim()?.takeIf { it.isNotBlank() } + ?: return + val digits = raw.replace(Regex("[^0-9]"), "") + if (digits.isEmpty()) return + val followerValue = try { + BigInteger(digits) + } catch (_: NumberFormatException) { + return + } + + onNextActivityCreate { + TextView::class.java.hook("setText", HookStage.BEFORE) { param -> + val textView = param.thisObject() as? TextView ?: return@hook + if (textView.javaClass.name !in COMPOSER_SNAP_TEXT_VIEW) return@hook + val arg0 = param.argNullable(0) ?: return@hook + if (arg0 !is CharSequence) return@hook + val text = arg0.toString() + if (!FOLLOWERS_LINE.containsMatchIn(text)) return@hook + val display = formatFollowersDisplay(followerValue) + val replaced = FOLLOWERS_LINE.replace(text) { mr -> + "$display ${mr.groupValues[2]}" + } + param.setArg(0, replaced) + } + } + } + + private fun formatFollowersDisplay(n: BigInteger): String { + val v = n.max(BigInteger.ZERO) + if (v < TEN_THOUSAND) { + return NumberFormat.getIntegerInstance(Locale.US).format(v.toLong()) + } + val (scaled, suffix) = scaleToTier(v) + return formatMantissaMaxFourDigits(scaled) + suffix + } + + private fun scaleToTier(v: BigInteger): Pair { + var i = SCALE_TIERS.indexOfLast { v >= it.floor }.coerceAtLeast(0) + var floor = SCALE_TIERS[i].floor + var suffix = SCALE_TIERS[i].suffix + var scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX) + while (scaled >= THOUSAND_BD && i + 1 < SCALE_TIERS.size) { + i++ + floor = SCALE_TIERS[i].floor + suffix = SCALE_TIERS[i].suffix + scaled = BigDecimal(v, MATH_CTX).divide(BigDecimal(floor, MATH_CTX), MATH_CTX) + } + return Pair(scaled, suffix) + } + + private fun formatMantissaMaxFourDigits(scaled: BigDecimal): String { + val x = scaled.abs().setScale(12, RoundingMode.HALF_UP) + if (x.compareTo(BigDecimal.ZERO) == 0) return "0" + if (x >= HUNDRED) { + val i = x.setScale(0, RoundingMode.HALF_UP) + var s = i.toPlainString() + if (digitCount(s) > 4) { + s = x.round(MathContext(4, RoundingMode.HALF_UP)).setScale(0, RoundingMode.HALF_UP).toPlainString() + } + return s + } + if (x >= TEN) { + var s = stripFrac(x.setScale(1, RoundingMode.HALF_UP)) + if (digitCount(s) > 4) { + s = x.setScale(0, RoundingMode.HALF_UP).toPlainString() + } + return s + } + var s = stripFrac(x.setScale(2, RoundingMode.HALF_UP)) + if (digitCount(s) > 4) { + s = stripFrac(x.setScale(1, RoundingMode.HALF_UP)) + } + if (digitCount(s) > 4) { + s = x.setScale(0, RoundingMode.HALF_UP).toPlainString() + } + return s + } + + private fun digitCount(s: String) = s.count { it.isDigit() } + + private fun stripFrac(d: BigDecimal): String { + var s = d.stripTrailingZeros().toPlainString() + if ('.' in s) { + s = s.trimEnd('0').trimEnd('.') + } + return s + } + + private data class ScaleTier(val floor: BigInteger, val suffix: String) + + companion object { + private val FOLLOWERS_LINE = Regex("^([\\d,]+)\\s+(Followers)\\b", RegexOption.IGNORE_CASE) + private val COMPOSER_SNAP_TEXT_VIEW = setOf( + "com.snap.valdi.views.ComposerSnapTextView", + "com.snap.composer.views.ComposerSnapTextView", + ) + private val MATH_CTX = MathContext(24, RoundingMode.HALF_UP) + private val TEN_THOUSAND = BigInteger("10000") + private val THOUSAND_BD = BigDecimal("1000") + private val HUNDRED = BigDecimal("100") + private val TEN = BigDecimal("10") + private val SCALE_TIERS = listOf( + ScaleTier(BigInteger("1000"), "K"), + ScaleTier(BigInteger("1000000"), "M"), + ScaleTier(BigInteger("1000000000"), "B"), + ScaleTier(BigInteger("1000000000000"), "T"), + ScaleTier(BigInteger("1000000000000000"), "Q"), + ScaleTier(BigInteger("1000000000000000000"), "E"), + ScaleTier(BigInteger("1000000000000000000000"), "Z"), + ScaleTier(BigInteger("1000000000000000000000000"), "Y"), + ) + } +}