feat: Spoof Followers Count by RSR (#142)

This commit is contained in:
RSR/
2026-04-25 17:29:46 +04:00
committed by GitHub
parent cb2c0dded9
commit ed66ae2998
6 changed files with 190 additions and 11 deletions

View File

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

View File

@@ -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": "وضع علامة مقروء على المحادثة عند إرسال رسالة",

View File

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

View File

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

View File

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

View File

@@ -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<Any>(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<BigDecimal, String> {
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"),
)
}
}