Sync(Feat): AI & showcase and Spoof Followers

This commit is contained in:
DarkKnight2122
2026-04-26 00:23:52 +05:30
28 changed files with 5749 additions and 2817 deletions

View File

@@ -113,7 +113,6 @@ android {
compose = true compose = true
buildConfig = true buildConfig = true
} }
signingConfigs { signingConfigs {
create("release") { create("release") {
storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore") storeFile = File(System.getProperty("user.home"), ".android/purrfectsnap-release.keystore")
@@ -359,6 +358,7 @@ afterEvaluate {
} }
} }
} }
} }
properties["debug_flavor"]?.let { properties["debug_flavor"]?.let {

View File

@@ -119,6 +119,22 @@ class AppDatabase(
"id CHAR(36) PRIMARY KEY", "id CHAR(36) PRIMARY KEY",
"content TEXT", "content TEXT",
), ),
"assistant_registry" to listOf(
"id VARCHAR PRIMARY KEY",
"kind VARCHAR",
"title VARCHAR",
"category VARCHAR",
"path TEXT",
"description TEXT",
"settingKey VARCHAR",
"screenRoute VARCHAR",
"allowedActions TEXT",
"allowedValues TEXT",
"aliases TEXT",
"commonTypos TEXT",
"examples TEXT",
"searchTokens TEXT",
),
)) ))
} }
} }

View File

@@ -0,0 +1,93 @@
package me.eternal.purrfectsnap.storage
import android.content.ContentValues
import me.eternal.purrfectsnap.common.util.ktx.getStringOrNull
import org.json.JSONArray
data class AssistantRegistryEntry(
val id: String,
val kind: String,
val title: String,
val category: String,
val path: String,
val description: String,
val settingKey: String? = null,
val screenRoute: String? = null,
val allowedActions: List<String> = emptyList(),
val allowedValues: List<String> = emptyList(),
val aliases: List<String> = emptyList(),
val commonTypos: List<String> = emptyList(),
val examples: List<String> = emptyList(),
val searchTokens: List<String> = emptyList()
)
private fun List<String>.toJsonArrayString(): String = JSONArray(this).toString()
private fun parseStringList(raw: String?): List<String> {
if (raw.isNullOrBlank()) return emptyList()
return runCatching {
val array = JSONArray(raw)
buildList {
for (index in 0 until array.length()) {
array.optString(index).takeIf { it.isNotBlank() }?.let(::add)
}
}
}.getOrDefault(emptyList())
}
fun AppDatabase.replaceAssistantRegistry(entries: List<AssistantRegistryEntry>) {
database.beginTransaction()
try {
database.execSQL("DELETE FROM assistant_registry")
entries.forEach { entry ->
database.insert(
"assistant_registry",
null,
ContentValues().apply {
put("id", entry.id)
put("kind", entry.kind)
put("title", entry.title)
put("category", entry.category)
put("path", entry.path)
put("description", entry.description)
put("settingKey", entry.settingKey)
put("screenRoute", entry.screenRoute)
put("allowedActions", entry.allowedActions.toJsonArrayString())
put("allowedValues", entry.allowedValues.toJsonArrayString())
put("aliases", entry.aliases.toJsonArrayString())
put("commonTypos", entry.commonTypos.toJsonArrayString())
put("examples", entry.examples.toJsonArrayString())
put("searchTokens", entry.searchTokens.toJsonArrayString())
}
)
}
database.setTransactionSuccessful()
} finally {
database.endTransaction()
}
}
fun AppDatabase.getAssistantRegistryEntries(): List<AssistantRegistryEntry> {
return database.rawQuery("SELECT * FROM assistant_registry", null).use { cursor ->
val entries = mutableListOf<AssistantRegistryEntry>()
while (cursor.moveToNext()) {
entries += AssistantRegistryEntry(
id = cursor.getStringOrNull("id") ?: continue,
kind = cursor.getStringOrNull("kind") ?: "feature",
title = cursor.getStringOrNull("title") ?: "",
category = cursor.getStringOrNull("category") ?: "",
path = cursor.getStringOrNull("path") ?: "",
description = cursor.getStringOrNull("description") ?: "",
settingKey = cursor.getStringOrNull("settingKey"),
screenRoute = cursor.getStringOrNull("screenRoute"),
allowedActions = parseStringList(cursor.getStringOrNull("allowedActions")),
allowedValues = parseStringList(cursor.getStringOrNull("allowedValues")),
aliases = parseStringList(cursor.getStringOrNull("aliases")),
commonTypos = parseStringList(cursor.getStringOrNull("commonTypos")),
examples = parseStringList(cursor.getStringOrNull("examples")),
searchTokens = parseStringList(cursor.getStringOrNull("searchTokens"))
)
}
entries
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -269,7 +269,7 @@ fun FloatingTopBar(
if (onBack != null) { if (onBack != null) {
translationX = morphingParams.horizontalShift.toPx() translationX = morphingParams.horizontalShift.toPx()
} }
}, },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
) { ) {

View File

@@ -4,18 +4,14 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.GroupAdd
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -29,6 +25,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -38,10 +35,10 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.eternal.purrfectsnap.common.data.MessagingRuleType import me.eternal.purrfectsnap.common.data.MessagingRuleType
import me.eternal.purrfectsnap.ui.manager.rememberRouteScrollState
import me.eternal.purrfectsnap.common.data.RuleState import me.eternal.purrfectsnap.common.data.RuleState
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableState
import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher import me.eternal.purrfectsnap.common.ui.rememberAsyncUpdateDispatcher
import me.eternal.purrfectsnap.common.ui.rememberAsyncMutableStateList
import me.eternal.purrfectsnap.storage.clearRuleIds import me.eternal.purrfectsnap.storage.clearRuleIds
import me.eternal.purrfectsnap.storage.getRuleIds import me.eternal.purrfectsnap.storage.getRuleIds
import me.eternal.purrfectsnap.storage.setRule import me.eternal.purrfectsnap.storage.setRule
@@ -140,9 +137,10 @@ class ManageRuleFeature : Routes.Route() {
} }
val updateDispatcher = rememberAsyncUpdateDispatcher() val updateDispatcher = rememberAsyncUpdateDispatcher()
val currentRuleIds by rememberAsyncMutableState(defaultValue = mutableListOf(), updateDispatcher = updateDispatcher) { val currentRuleIds = rememberAsyncMutableStateList(defaultValue = emptyList()) {
context.database.getRuleIds(currentRuleType.key) context.database.getRuleIds(currentRuleType.key)
} }
val ruleIdsSet by remember { derivedStateOf { currentRuleIds.toSet() } }
fun setRuleState(newState: RuleState?) { fun setRuleState(newState: RuleState?) {
ruleState = newState ruleState = newState
@@ -163,12 +161,12 @@ class ManageRuleFeature : Routes.Route() {
fun showAddFriendDialog() { fun showAddFriendDialog() {
addFriendDialog = AddFriendDialog( addFriendDialog = AddFriendDialog(
context = context, context = context,
pinnedIds = currentRuleIds, pinnedIds = currentRuleIds.toList(),
actionHandler = Actions( actionHandler = Actions(
onFriendState = { friend, state -> onFriendState = { friend, state ->
context.database.setRule(friend.userId, currentRuleType.key, state) context.database.setRule(friend.userId, currentRuleType.key, state)
if (state) { if (state) {
currentRuleIds.add(friend.userId) if (!currentRuleIds.contains(friend.userId)) currentRuleIds.add(friend.userId)
} else { } else {
currentRuleIds.remove(friend.userId) currentRuleIds.remove(friend.userId)
} }
@@ -176,16 +174,16 @@ class ManageRuleFeature : Routes.Route() {
onGroupState = { group, state -> onGroupState = { group, state ->
context.database.setRule(group.conversationId, currentRuleType.key, state) context.database.setRule(group.conversationId, currentRuleType.key, state)
if (state) { if (state) {
currentRuleIds.add(group.conversationId) if (!currentRuleIds.contains(group.conversationId)) currentRuleIds.add(group.conversationId)
} else { } else {
currentRuleIds.remove(group.conversationId) currentRuleIds.remove(group.conversationId)
} }
}, },
getFriendState = { friend -> getFriendState = { friend ->
currentRuleIds.contains(friend.userId) ruleIdsSet.contains(friend.userId)
}, },
getGroupState = { group -> getGroupState = { group ->
currentRuleIds.contains(group.conversationId) ruleIdsSet.contains(group.conversationId)
} }
) )
) )
@@ -230,59 +228,62 @@ class ManageRuleFeature : Routes.Route() {
title = remember { context.translation[propertyKeyPair.key.propertyName()] }, title = remember { context.translation[propertyKeyPair.key.propertyName()] },
onBack = { routes.navController.popBackStack() }, onBack = { routes.navController.popBackStack() },
modifier = Modifier modifier = Modifier
.zIndex(2f) .zIndex(10f)
.onGloballyPositioned { .onGloballyPositioned {
val newHeight = with(density) { it.size.height.toDp() } val newHeight = with(density) { it.size.height.toDp() }
if (newHeight != topBarHeight) topBarHeight = newHeight if (newHeight != topBarHeight) topBarHeight = newHeight
} }
) )
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = topBarHeight + 10.dp) .padding(top = topBarHeight + 10.dp),
.padding(horizontal = 12.dp, vertical = 10.dp) contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
.verticalScroll(rememberRouteScrollState(routeInfo.id)),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
val headerShape = RoundedCornerShape(22.dp) item {
Surface( val headerShape = RoundedCornerShape(22.dp)
shape = headerShape, Surface(
color = Color.White.copy(alpha = 0.04f), shape = headerShape,
tonalElevation = 0.dp, color = Color.White.copy(alpha = 0.04f),
shadowElevation = 0.dp, tonalElevation = 0.dp,
border = BorderStroke( shadowElevation = 0.dp,
1.dp, border = BorderStroke(
Brush.linearGradient( 1.dp,
listOf( Brush.linearGradient(
PurrfectPalette.glowPrimary.copy(alpha = 0.45f), listOf(
PurrfectPalette.glowSecondary.copy(alpha = 0.35f) PurrfectPalette.glowPrimary.copy(alpha = 0.45f),
PurrfectPalette.glowSecondary.copy(alpha = 0.35f)
)
) )
) )
)
) {
Column(
modifier = Modifier
.background(PurrfectPalette.cardOverlay, headerShape)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text( Column(
text = context.translation[propertyKeyPair.key.propertyDescription()], modifier = Modifier
fontWeight = FontWeight.Normal, .background(PurrfectPalette.cardOverlay, headerShape)
fontSize = 12.sp, .padding(horizontal = 16.dp, vertical = 14.dp),
lineHeight = 16.sp, verticalArrangement = Arrangement.spacedBy(6.dp)
color = PurrfectPalette.textSecondary ) {
) Text(
text = context.translation[propertyKeyPair.key.propertyDescription()] ?: "",
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
color = PurrfectPalette.textSecondary
)
}
} }
} }
SelectRuleTypeRadio( item {
checked = ruleState == null, SelectRuleTypeRadio(
text = translation["disable_state_option"], checked = ruleState == null,
onStateChanged = { setRuleState(null) } text = translation["disable_state_option"] ?: "Disabled",
) { onStateChanged = { setRuleState(null) }
Text(text = translation["disable_state_subtext"], fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary) ) {
Text(text = translation["disable_state_subtext"] ?: "", fontWeight = FontWeight.Normal, fontSize = 12.sp, color = PurrfectPalette.textSecondary)
}
} }
val manageLabel = when (ruleState) { val manageLabel = when (ruleState) {
@@ -291,112 +292,120 @@ class ManageRuleFeature : Routes.Route() {
else -> null else -> null
} }
SelectRuleTypeRadio( item {
checked = ruleState == RuleState.WHITELIST, SelectRuleTypeRadio(
text = translation["whitelist_state_option"], checked = ruleState == RuleState.WHITELIST,
onStateChanged = { setRuleState(RuleState.WHITELIST) } text = translation["whitelist_state_option"] ?: "Whitelist",
) { onStateChanged = { setRuleState(RuleState.WHITELIST) }
Text( ) {
text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()), Text(
fontWeight = FontWeight.Normal, text = translation.format("whitelist_state_subtext", "count" to currentRuleIds.size.toString()),
fontSize = 12.sp, fontWeight = FontWeight.Normal,
color = PurrfectPalette.textSecondary fontSize = 12.sp,
) color = PurrfectPalette.textSecondary
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
) )
) {
Text(text = translation["whitelist_state_button"])
}
}
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"],
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"])
}
}
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"],
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button( Button(
onClick = { confirmationDialog = true }, onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f), containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White contentColor = Color.White
) )
) { ) {
Text(text = translation["dialog_clear_confirm_button"]) Text(text = translation["whitelist_state_button"] ?: "Manage")
} }
} }
} }
Spacer(modifier = Modifier.height(routes.bottomPadding)) item {
SelectRuleTypeRadio(
checked = ruleState == RuleState.BLACKLIST,
text = translation["blacklist_state_option"] ?: "Blacklist",
onStateChanged = { setRuleState(RuleState.BLACKLIST) }
) {
Text(
text = translation.format("blacklist_state_subtext", "count" to currentRuleIds.size.toString()),
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
color = PurrfectPalette.textSecondary
)
Button(
onClick = { showAddFriendDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = PurrfectPalette.glowPrimary.copy(alpha = 0.34f),
contentColor = Color.White
)
) {
Text(text = translation["blacklist_state_button"] ?: "Manage")
}
}
}
item {
Surface(
shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.04f),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(PurrfectPalette.cardOverlay, RoundedCornerShape(22.dp))
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Surface(
shape = CircleShape,
color = Color.White.copy(alpha = 0.08f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)),
modifier = Modifier.size(46.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PurrfectPalette.glowSecondary.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.DeleteSweep, contentDescription = null, tint = Color.White)
}
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = translation["clear_list_button"] ?: "Clear List",
color = Color.White,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!manageLabel.isNullOrBlank()) {
Text(
text = manageLabel,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Button(
onClick = { confirmationDialog = true },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White
)
) {
Text(text = translation["dialog_clear_confirm_button"] ?: "Clear")
}
}
}
}
item {
Spacer(modifier = Modifier.height(routes.bottomPadding))
}
} }
} }
} }

View File

@@ -32,7 +32,6 @@ import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.util.PurrfectMarqueeText
import me.eternal.purrfectsnap.ui.util.scaleOnPress import me.eternal.purrfectsnap.ui.util.scaleOnPress
class HomeAbout : Routes.Route() { class HomeAbout : Routes.Route() {
@@ -65,6 +64,7 @@ class HomeAbout : Routes.Route() {
name: String, name: String,
imageRes: Int, imageRes: Int,
avenirNext: FontFamily, avenirNext: FontFamily,
subtitle: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val tapSource = remember { MutableInteractionSource() } val tapSource = remember { MutableInteractionSource() }
@@ -85,7 +85,9 @@ class HomeAbout : Routes.Route() {
routes.retroGame.navigate() routes.retroGame.navigate()
} }
}, },
modifier = modifier.scaleOnPress(tapSource), modifier = modifier
.height(150.dp)
.scaleOnPress(tapSource),
interactionSource = tapSource, interactionSource = tapSource,
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f), color = Color.White.copy(alpha = 0.06f),
@@ -94,9 +96,11 @@ class HomeAbout : Routes.Route() {
shadowElevation = 0.dp shadowElevation = 0.dp
) { ) {
Column( Column(
modifier = Modifier.padding(14.dp), modifier = Modifier
.fillMaxSize()
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.Center
) { ) {
Surface( Surface(
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
@@ -111,15 +115,28 @@ class HomeAbout : Routes.Route() {
modifier = Modifier.fillMaxSize().clip(CircleShape) modifier = Modifier.fillMaxSize().clip(CircleShape)
) )
} }
PurrfectMarqueeText( Text(
text = name, text = name,
color = Color.White, color = Color.White,
style = TextStyle( fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Bold, fontSize = 16.sp,
fontSize = 16.sp, fontFamily = avenirNext,
fontFamily = avenirNext textAlign = androidx.compose.ui.text.style.TextAlign.Center,
) maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
) )
subtitle?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = it,
color = PurrfectPalette.textSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
} }

View File

@@ -127,6 +127,8 @@ import me.eternal.purrfectsnap.storage.getQuickTiles
import me.eternal.purrfectsnap.storage.setQuickTiles import me.eternal.purrfectsnap.storage.setQuickTiles
import me.eternal.purrfectsnap.ui.manager.Routes import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.ManagerTheme import me.eternal.purrfectsnap.ui.manager.ManagerTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater import me.eternal.purrfectsnap.ui.manager.data.Updater
@@ -271,27 +273,31 @@ class HomeRootSection : Routes.Route() {
icon: ImageVector, icon: ImageVector,
label: String? = null, label: String? = null,
contentDescription: String? = label, contentDescription: String? = label,
modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Surface( Surface(
modifier = modifier.height(36.dp),
shape = RoundedCornerShape(40), shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f), color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40)) .clip(RoundedCornerShape(40))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp), .padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.Center
) { ) {
Icon(icon, contentDescription = contentDescription, tint = Color.White) Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
label?.let { label?.let {
Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = it, text = it,
color = Color.White, color = Color.White,
fontSize = 13.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@@ -323,13 +329,20 @@ class HomeRootSection : Routes.Route() {
@Composable @Composable
private fun RowScope.HomeActionChips() { private fun RowScope.HomeActionChips() {
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.DEFAULT
)
TopBarActionChip( TopBarActionChip(
icon = Icons.Filled.BugReport, icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"] label = context.translation["manager.routes.home_logs"],
modifier = Modifier
) { routes.homeLogs.navigate() } ) { routes.homeLogs.navigate() }
TopBarActionChip( TopBarActionChip(
icon = Icons.Filled.Info, icon = Icons.Filled.Info,
label = translation["manager.routes.home_about"] label = translation["manager.routes.home_about"],
modifier = Modifier
) { routes.about.navigate() } ) { routes.about.navigate() }
} }
@@ -733,9 +746,8 @@ class HomeRootSection : Routes.Route() {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.wrapContentWidth(),
) { ) {
Spacer(modifier = Modifier.weight(1f))
HomeActionChips() HomeActionChips()
} }
} }

View File

@@ -59,8 +59,11 @@ class SocialRootSection : Routes.Route() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val dbFriends = context.database.getFriends(descOrder = true) val dbFriends = context.database.getFriends(descOrder = true)
val dbGroups = context.database.getGroups() val dbGroups = context.database.getGroups()
friendList = context.sortSocialFriends(dbFriends) val sortedFriends = context.sortSocialFriends(dbFriends)
groupList = dbGroups withContext(Dispatchers.Main) {
friendList = sortedFriends
groupList = dbGroups
}
} }
// Real-time synchronization from the bridge // Real-time synchronization from the bridge

View File

@@ -135,13 +135,26 @@ fun HomeAbout.AphelionAboutScreen(nav: NavBackStackEntry) {
color = Color.White, color = Color.White,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(bottom = 4.dp)
) )
Row( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(12.dp)
verticalAlignment = Alignment.CenterVertically
) { ) {
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f)) Row(
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f)) modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "Eternal", subtitle = "", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "Kaladin", subtitle = "", imageRes = R.drawable.pfp_kaladin, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
DeveloperCard(name = "schrodingerspet", subtitle = "", imageRes = R.drawable.pfp_schrodingerspet, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "RSR", subtitle = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
} }
} }
} }

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.lerp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@@ -63,6 +64,8 @@ import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader import me.eternal.purrfectsnap.ui.manager.data.UpdateDownloader
import me.eternal.purrfectsnap.ui.manager.data.Updater import me.eternal.purrfectsnap.ui.manager.data.Updater
import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel import me.eternal.purrfectsnap.ui.manager.data.Updater.Channel
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection import me.eternal.purrfectsnap.ui.manager.pages.home.HomeRootSection
import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog import me.eternal.purrfectsnap.ui.manager.pages.home.QuickActionsDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
@@ -148,11 +151,15 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
label: String? = null, label: String? = null,
contentDescription: String? = label, contentDescription: String? = label,
shrinkFactor: Float = 1f, shrinkFactor: Float = 1f,
modifier: Modifier = Modifier,
expandedWidth: Dp? = null,
collapsedWidth: Dp = 36.dp,
haptic: HapticFeedback, haptic: HapticFeedback,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val targetWidth = expandedWidth?.let { lerp(collapsedWidth, it, shrinkFactor) }
Surface( Surface(
modifier = Modifier.height(36.dp).widthIn(min = 36.dp), modifier = modifier.height(36.dp).then(if (targetWidth != null) Modifier.width(targetWidth) else Modifier),
shape = RoundedCornerShape(40), shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f), color = Color.White.copy(alpha = 0.06f),
border = BorderStroke( border = BorderStroke(
@@ -167,9 +174,10 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40)) .clip(RoundedCornerShape(40))
.clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() } .clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress); onClick() }
.padding(vertical = 6.dp, horizontal = lerp(10.dp, 12.dp, shrinkFactor)), .padding(vertical = 6.dp, horizontal = lerp(7.dp, 10.dp, shrinkFactor)),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@@ -182,17 +190,19 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
} }
) )
if (label != null) { if (label != null) {
val labelAlpha = (shrinkFactor - 0.1f).coerceIn(0f, 1f) val labelAlpha = ((shrinkFactor - 0.45f) / 0.55f).coerceIn(0f, 1f)
Spacer(modifier = Modifier.width((8 * shrinkFactor).dp)) if (labelAlpha > 0.02f) {
Text( Spacer(modifier = Modifier.width((8 * shrinkFactor).dp))
text = label, Text(
color = Color.White.copy(alpha = labelAlpha), text = label,
fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = labelAlpha),
maxLines = 1, overflow = TextOverflow.Clip, fontSize = 12.sp, fontWeight = FontWeight.Medium,
modifier = Modifier maxLines = 1, overflow = TextOverflow.Ellipsis,
.graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() } modifier = Modifier
.widthIn(max = (75 * shrinkFactor).dp) .graphicsLayer { alpha = labelAlpha; translationX = (-4 * (1f - shrinkFactor)).dp.toPx() }
) .weight(1f, fill = false)
)
}
} }
} }
} }
@@ -206,14 +216,23 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
val shrinkFactor by remember(scrollState.value) { val shrinkFactor by remember(scrollState.value) {
derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) } derivedStateOf { (1f - (scrollState.value.toFloat() / Motion.HEADER_MORPH_THRESHOLD)).coerceIn(0f, 1f) }
} }
ManagerAssistantEntry(
context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.APHELION,
shrinkFactor = shrinkFactor,
modifier = Modifier.width(lerp(36.dp, 66.dp, shrinkFactor))
)
AphelionTopBarActionChip( AphelionTopBarActionChip(
icon = Icons.Filled.BugReport, icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"], label = context.translation["manager.routes.home_logs"],
expandedWidth = 88.dp,
shrinkFactor = shrinkFactor, haptic = haptic shrinkFactor = shrinkFactor, haptic = haptic
) { routes.homeLogs.navigate() } ) { routes.homeLogs.navigate() }
AphelionTopBarActionChip( AphelionTopBarActionChip(
icon = Icons.Filled.Settings, icon = Icons.Filled.Settings,
label = context.translation["manager.routes.home_settings"], label = context.translation["manager.routes.home_settings"],
expandedWidth = 96.dp,
shrinkFactor = shrinkFactor, haptic = haptic shrinkFactor = shrinkFactor, haptic = haptic
) { routes.settings.navigate() } ) { routes.settings.navigate() }
} }
@@ -645,37 +664,27 @@ fun HomeRootSection.AphelionHomeScreen(nav: NavBackStackEntry) {
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.height(headerHeight) .height(headerHeight)
) { ) {
Text(
text = "PurrfectSnap",
color = Color.White.copy(alpha = stickyBrandingAlpha),
fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = avenirNext,
modifier = Modifier.align(Alignment.Center)
)
val announcementShift by remember(focusFactor) { derivedStateOf { (-6 * focusFactor).dp } }
Row( Row(
modifier = Modifier.align(Alignment.CenterStart).graphicsLayer { translationX = announcementShift.toPx() }, modifier = Modifier
.align(Alignment.Center)
.wrapContentWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
AphelionTopBarActionChip( AphelionTopBarActionChip(
icon = Icons.Filled.Notifications, label = null, icon = Icons.Filled.Notifications, label = null,
expandedWidth = 52.dp,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f), shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation["announcements_button_description"], contentDescription = translation["announcements_button_description"],
haptic = haptic haptic = haptic
) { showAnnouncementsDialog = true; loadAnnouncements() } ) { showAnnouncementsDialog = true; loadAnnouncements() }
AphelionTopBarActionChip( AphelionTopBarActionChip(
icon = Icons.Filled.Description, label = null, icon = Icons.Filled.Description, label = null,
expandedWidth = 52.dp,
shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f), shrinkFactor = (1f - focusFactor).coerceIn(0f, 1f),
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog", contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog",
haptic = haptic haptic = haptic
) { showFullChangelogDialog = true; loadFullChangelog() } ) { showFullChangelogDialog = true; loadFullChangelog() }
}
val settingsShift by remember(focusFactor) { derivedStateOf { (6 * focusFactor).dp } }
Row(
modifier = Modifier.align(Alignment.CenterEnd).graphicsLayer { translationX = settingsShift.toPx() },
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AphelionHomeActionChips(scrollState = scrollState, haptic = haptic) AphelionHomeActionChips(scrollState = scrollState, haptic = haptic)
} }
} }

View File

@@ -78,6 +78,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.LogLine import me.eternal.purrfectsnap.LogLine
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantEntry
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantTriggerStyle
import me.eternal.purrfectsnap.LogReader import me.eternal.purrfectsnap.LogReader
import me.eternal.purrfectsnap.R import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.action.EnumQuickActions import me.eternal.purrfectsnap.action.EnumQuickActions
@@ -154,24 +156,28 @@ object LegacyTheme : ThemeContract {
icon: ImageVector, icon: ImageVector,
label: String? = null, label: String? = null,
contentDescription: String? = label, contentDescription: String? = label,
modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Surface( Surface(
modifier = modifier.height(36.dp),
shape = RoundedCornerShape(40), shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.06f), color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f)) border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(40)) .clip(RoundedCornerShape(40))
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 8.dp), .padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.Center
) { ) {
Icon(icon, contentDescription = contentDescription, tint = Color.White) Icon(icon, contentDescription = contentDescription, tint = Color.White, modifier = Modifier.size(20.dp))
label?.let { label?.let {
Text(text = it, color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) Spacer(modifier = Modifier.width(6.dp))
Text(text = it, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
} }
@@ -179,8 +185,22 @@ object LegacyTheme : ThemeContract {
@Composable @Composable
fun RowScope.LocalHomeActionChips() { fun RowScope.LocalHomeActionChips() {
LocalTopBarActionChip(icon = Icons.Filled.BugReport, label = context.translation["manager.routes.home_logs"]) { routes.homeLogs.navigate() } ManagerAssistantEntry(
LocalTopBarActionChip(icon = Icons.Filled.Info, label = translation["manager.routes.home_about"]) { routes.about.navigate() } context = context,
routes = routes,
style = ManagerAssistantTriggerStyle.DEFAULT,
modifier = Modifier.weight(1f)
)
LocalTopBarActionChip(
icon = Icons.Filled.BugReport,
label = context.translation["manager.routes.home_logs"],
modifier = Modifier.weight(1f)
) { routes.homeLogs.navigate() }
LocalTopBarActionChip(
icon = Icons.Filled.Info,
label = translation["manager.routes.home_about"],
modifier = Modifier.weight(1f)
) { routes.about.navigate() }
} }
@Composable @Composable
@@ -476,18 +496,26 @@ object LegacyTheme : ThemeContract {
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(bottom = contentBottomPadding)) { Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(bottom = contentBottomPadding)) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(WindowInsets.statusBars.asPaddingValues()).padding(horizontal = cardMargin, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(WindowInsets.statusBars.asPaddingValues()).padding(horizontal = cardMargin, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { LocalTopBarActionChip(
LocalTopBarActionChip(icon = Icons.Filled.Notifications, label = null, contentDescription = translation["announcements_button_description"]) { icon = Icons.Filled.Notifications,
showAnnouncementsDialog = true; loadAnnouncements() label = null,
} modifier = Modifier.width(56.dp),
LocalTopBarActionChip(icon = Icons.Filled.Description, label = null, contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog") { contentDescription = translation["announcements_button_description"]
showFullChangelogDialog = true; loadFullChangelog(changelogUrl) ) { showAnnouncementsDialog = true; loadAnnouncements() }
} LocalTopBarActionChip(
} icon = Icons.Filled.Description,
Row(modifier = Modifier.wrapContentWidth(Alignment.End), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { label = null,
modifier = Modifier.width(56.dp),
contentDescription = translation.getOrNull("changelog_button_description") ?: "Open full changelog"
) { showFullChangelogDialog = true; loadFullChangelog(changelogUrl) }
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
LocalHomeActionChips() LocalHomeActionChips()
} }
} }
@@ -1705,9 +1733,15 @@ object LegacyTheme : ThemeContract {
) )
Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = Color(0xFFD9D3FF), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) Text(text = translation["about_tagline"] ?: "", fontSize = 13.sp, color = Color(0xFFD9D3FF), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth())
Text(text = translation["about_lead_developers_title"] ?: "Lead Developers", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier.padding(top = 10.dp)) Text(text = translation["about_lead_developers_title"] ?: "Lead Developers", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Color.White, modifier = Modifier.padding(top = 10.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
DeveloperCard(name = "ΞTΞRNAL", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
DeveloperCard(name = "<RSR/>", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f)) DeveloperCard(name = "Eternal", subtitle = "", imageRes = R.drawable.pfp_external, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "Kaladin", subtitle = "", imageRes = R.drawable.pfp_kaladin, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
DeveloperCard(name = "schrodingerspet", subtitle = "", imageRes = R.drawable.pfp_schrodingerspet, avenirNext = avenirNext, modifier = Modifier.weight(1f))
DeveloperCard(name = "RSR", subtitle = "", imageRes = R.drawable.pfp_rsr, avenirNext = avenirNext, modifier = Modifier.weight(1f))
}
} }
} }
} }
@@ -1769,11 +1803,14 @@ object LegacyTheme : ThemeContract {
name: String, name: String,
imageRes: Int, imageRes: Int,
avenirNext: FontFamily, avenirNext: FontFamily,
subtitle: String? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val tapSource = remember { MutableInteractionSource() } val tapSource = remember { MutableInteractionSource() }
Surface( Surface(
modifier = modifier.scaleOnPress(tapSource), modifier = modifier
.height(150.dp)
.scaleOnPress(tapSource),
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
color = Color.White.copy(alpha = 0.06f), color = Color.White.copy(alpha = 0.06f),
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)), border = BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)),
@@ -1781,9 +1818,11 @@ object LegacyTheme : ThemeContract {
shadowElevation = 0.dp shadowElevation = 0.dp
) { ) {
Column( Column(
modifier = Modifier.padding(14.dp), modifier = Modifier
.fillMaxSize()
.padding(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.Center
) { ) {
Surface( Surface(
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
@@ -1798,15 +1837,28 @@ object LegacyTheme : ThemeContract {
modifier = Modifier.fillMaxSize().clip(CircleShape) modifier = Modifier.fillMaxSize().clip(CircleShape)
) )
} }
PurrfectMarqueeText( Text(
text = name, text = name,
color = Color.White, color = Color.White,
style = TextStyle( fontWeight = FontWeight.Bold,
fontWeight = FontWeight.Bold, fontSize = 16.sp,
fontSize = 16.sp, fontFamily = avenirNext,
fontFamily = avenirNext textAlign = TextAlign.Center,
) maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
) )
subtitle?.takeIf { it.isNotBlank() }?.let {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = it,
color = Color(0xFFD9D3FF),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
} }

View File

@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Flag
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material.icons.filled.VerifiedUser import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -93,6 +94,8 @@ import androidx.navigation.compose.rememberNavController
import me.eternal.purrfectsnap.RemoteSideContext import me.eternal.purrfectsnap.RemoteSideContext
import me.eternal.purrfectsnap.SharedContextHolder import me.eternal.purrfectsnap.SharedContextHolder
import me.eternal.purrfectsnap.common.ui.AppMaterialTheme import me.eternal.purrfectsnap.common.ui.AppMaterialTheme
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -104,6 +107,8 @@ import me.eternal.purrfectsnap.ui.setup.screens.impl.PickLanguageScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen import me.eternal.purrfectsnap.ui.setup.screens.impl.PatchSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen import me.eternal.purrfectsnap.ui.setup.screens.impl.RootInstallSnapchatScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen import me.eternal.purrfectsnap.ui.setup.screens.impl.SaveFolderScreen
import me.eternal.purrfectsnap.ui.setup.screens.impl.IntroShowcaseScreen
import me.eternal.purrfectsnap.ui.util.ActivityLauncherHelper
import me.eternal.purrfectsnap.ui.util.scaleOnPress import me.eternal.purrfectsnap.ui.util.scaleOnPress
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -127,6 +132,9 @@ class SetupActivity : ComponentActivity() {
} }
val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN) val requirements = intent.getIntExtra("requirements", Requirements.FIRST_RUN)
val setupPrefs = setupContext.sharedPreferences val setupPrefs = setupContext.sharedPreferences
val setupRoutes = Routes(setupContext).apply {
activityLauncher = ActivityLauncherHelper(this@SetupActivity)
}
fun hasRequirement(requirement: Int) = requirements and requirement == requirement fun hasRequirement(requirement: Int) = requirements and requirement == requirement
val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false) val wasInProgress = setupPrefs.getBoolean("setup_in_progress", false)
val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress val isFirstRunFlow = hasRequirement(Requirements.FIRST_RUN) || wasInProgress
@@ -159,6 +167,7 @@ class SetupActivity : ComponentActivity() {
val requiredScreens = mutableListOf<SetupScreen>().apply { val requiredScreens = mutableListOf<SetupScreen>().apply {
if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) { if (isFirstRunFlow || hasRequirement(Requirements.LANGUAGE)) {
add(IntroShowcaseScreen().apply { route = "introShowcase" })
add(PickLanguageScreen().apply { route = "language" }) add(PickLanguageScreen().apply { route = "language" })
if (isFirstRunFlow) { if (isFirstRunFlow) {
add(InstallModeScreen( add(InstallModeScreen(
@@ -315,19 +324,7 @@ class SetupActivity : ComponentActivity() {
AppMaterialTheme { AppMaterialTheme {
val view = LocalView.current val view = LocalView.current
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
var showImportantDialog by rememberSaveable { var setupAiPrompt by rememberSaveable { mutableStateOf<String?>(null) }
mutableStateOf(!setupPrefs.getBoolean("setup_important_notice_shown", false))
}
var importantTimeout by remember { mutableIntStateOf(5) }
LaunchedEffect(showImportantDialog) {
if (showImportantDialog) {
importantTimeout = 5
while (importantTimeout > 0) {
delay(1000)
importantTimeout--
}
}
}
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
@@ -344,46 +341,8 @@ class SetupActivity : ComponentActivity() {
.fillMaxSize() .fillMaxSize()
.background(Color.Transparent) .background(Color.Transparent)
) { ) {
if (showImportantDialog) {
val confirmLabel = if (importantTimeout > 0) {
translation.format(
"setup.activity.important_confirm_timeout",
"seconds" to importantTimeout.toString()
)
} else {
translation["setup.activity.important_confirm"]
}
AestheticDialog(
onDismissRequest = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
title = translation["setup.activity.important_title"],
text = "",
icon = Icons.Filled.Warning,
confirmButtonText = confirmLabel,
onConfirm = {
if (importantTimeout == 0) {
showImportantDialog = false
setupPrefs.edit().putBoolean("setup_important_notice_shown", true).apply()
}
},
confirmEnabled = importantTimeout == 0,
showCloseButton = false,
customContent = {
Text(
text = translation["setup.activity.important_message"],
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
},
opaque = true
)
}
SetupAuroraBackground() SetupAuroraBackground()
SetupTopBar() SetupTopBar(onAskAi = { setupAiPrompt = "hi" })
val bottomPadding = 118.dp + navBarPadding val bottomPadding = 118.dp + navBarPadding
Column( Column(
modifier = Modifier modifier = Modifier
@@ -468,6 +427,14 @@ class SetupActivity : ComponentActivity() {
.navigationBarsPadding() .navigationBarsPadding()
.padding(bottom = 32.dp) .padding(bottom = 32.dp)
) )
setupAiPrompt?.let { prompt ->
ManagerAssistantDialog(
context = setupContext,
routes = setupRoutes,
initialUserMessage = prompt,
onDismiss = { setupAiPrompt = null }
)
}
} }
} }
} }
@@ -483,6 +450,12 @@ private fun SetupScreen.meta(context: RemoteSideContext): SetupStepMeta {
subtitle = translation["setup.activity.language_subtitle"], subtitle = translation["setup.activity.language_subtitle"],
icon = Icons.Filled.Language icon = Icons.Filled.Language
) )
is IntroShowcaseScreen -> SetupStepMeta(
route = route,
title = "Welcome",
subtitle = "Preview what PurrfectSnap can do",
icon = Icons.Filled.AutoAwesome
)
is InstallModeScreen -> SetupStepMeta( is InstallModeScreen -> SetupStepMeta(
route = route, route = route,
@@ -587,7 +560,7 @@ private fun SetupAuroraBackground() {
} }
@Composable @Composable
private fun SetupTopBar() { private fun SetupTopBar(onAskAi: () -> Unit) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -613,14 +586,37 @@ private fun SetupTopBar() {
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 16.dp), .padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
text = "PurrfectSnap", text = "PurrfectSnap",
color = PurrfectPalette.textPrimary, color = PurrfectPalette.textPrimary,
fontWeight = FontWeight.ExtraBold, fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp fontSize = 18.sp,
modifier = Modifier.weight(1f)
) )
Surface(
shape = RoundedCornerShape(40),
color = Color.White.copy(alpha = 0.08f),
border = androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.14f))
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(40))
.clickable(onClick = onAskAi)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(Icons.Filled.SmartToy, contentDescription = null, tint = Color.White)
Text(
text = "Ask AI",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 13.sp
)
}
}
} }
} }
} }

View File

@@ -0,0 +1,112 @@
package me.eternal.purrfectsnap.ui.setup.screens.impl
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.eternal.purrfectsnap.R
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
class IntroShowcaseScreen : SetupScreen() {
private val slides = listOf(
R.drawable.setup_slide_plus to "Unlock Snapchat Plus for free!",
R.drawable.setup_slide_upload_tag to "Bypass the Media Upload tag!",
R.drawable.setup_slide_downloads to "Download Snaps, & Spotlights!"
)
@Composable
override fun Content() {
LaunchedEffect(Unit) { allowNext(true) }
var currentIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(5000)
currentIndex = (currentIndex + 1) % slides.size
}
}
SetupCard {
StepTitle(
title = "Welcome to PurrfectSnap",
subtitle = "A quick look before setup begins",
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center
)
AnimatedContent(targetState = currentIndex, label = "setupShowcase") { index ->
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(260.dp),
shape = RoundedCornerShape(24.dp),
color = PurrfectPalette.cardOverlayColor,
border = BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))
) {
Image(
painter = painterResource(slides[index].first),
contentDescription = slides[index].second,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp))
)
}
Text(
text = slides[index].second,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
slides.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(if (index == currentIndex) 10.dp else 8.dp)
.clip(CircleShape)
.background(if (index == currentIndex) PurrfectPalette.glowPrimary else Color.White.copy(alpha = 0.3f))
)
}
}
}
}
}

View File

@@ -74,6 +74,8 @@ import kotlinx.coroutines.withContext
import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper import me.eternal.purrfectsnap.common.bridge.wrapper.LocaleWrapper
import me.eternal.purrfectsnap.setup.patch.AutoPatchServer import me.eternal.purrfectsnap.setup.patch.AutoPatchServer
import me.eternal.purrfectsnap.setup.patch.LSPatch import me.eternal.purrfectsnap.setup.patch.LSPatch
import me.eternal.purrfectsnap.ui.manager.ManagerAssistantDialog
import me.eternal.purrfectsnap.ui.manager.Routes
import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog import me.eternal.purrfectsnap.ui.manager.components.AestheticDialog
import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette import me.eternal.purrfectsnap.ui.manager.theme.PurrfectPalette
import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen import me.eternal.purrfectsnap.ui.setup.screens.SetupScreen
@@ -111,6 +113,10 @@ class PatchSnapchatScreen : SetupScreen() {
var installWatcher by remember { mutableStateOf<Job?>(null) } var installWatcher by remember { mutableStateOf<Job?>(null) }
var downloadFinished by rememberSaveable { mutableStateOf(false) } var downloadFinished by rememberSaveable { mutableStateOf(false) }
var showIssuesDialog by remember { mutableStateOf(false) } var showIssuesDialog by remember { mutableStateOf(false) }
val assistantRoutes = remember {
Routes(context).apply {
}
}
val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat( val logPulse by rememberInfiniteTransition(label = "logPulse").animateFloat(
initialValue = 0f, initialValue = 0f,
targetValue = 1f, targetValue = 1f,
@@ -313,62 +319,12 @@ class PatchSnapchatScreen : SetupScreen() {
} }
if (showIssuesDialog) { if (showIssuesDialog) {
AestheticDialog( ManagerAssistantDialog(
onDismissRequest = { showIssuesDialog = false }, context = context,
title = translation["setup.patch.issues_title"], routes = assistantRoutes,
text = "", initialUserMessage = "I am facing an App not installed issue or Package appears to be invalid issue while installing Snapchat. How do I fix it?",
icon = Icons.Filled.Info, showImprovementLogging = false,
confirmButtonText = translation["setup.patch.issues_confirm"], onDismiss = { showIssuesDialog = false }
onConfirm = { showIssuesDialog = false },
showCloseButton = false,
customContent = {
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(
color = PurrfectPalette.textSecondary,
lineHeight = 18.sp
)
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 360.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = translation["setup.patch.issues_heading"],
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
Text(
text = translation["setup.patch.issues_conflict_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_conflict_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_adb_command"],
style = bodyStyle,
textAlign = TextAlign.Start,
softWrap = false,
modifier = Modifier.horizontalScroll(rememberScrollState())
)
Text(
text = translation["setup.patch.issues_invalid_issue"],
style = bodyStyle,
textAlign = TextAlign.Start
)
Text(
text = translation["setup.patch.issues_invalid_fix"],
style = bodyStyle,
textAlign = TextAlign.Start
)
}
}
) )
} }

View File

@@ -431,9 +431,10 @@ class AlertDialogs(
DefaultDialogCard { DefaultDialogCard {
var fieldValue by remember { var fieldValue by remember {
mutableStateOf(property.value.get().toString().let { mutableStateOf(property.value.get().toString().let {
val t = if (property.key.params.digitsOnlyInput) it.filter { ch -> ch.isDigit() } else it
TextFieldValue( TextFieldValue(
text = it, text = t,
selection = TextRange(it.length) selection = TextRange(t.length)
) )
}) })
} }
@@ -447,10 +448,21 @@ class AlertDialogs(
} }
.focusRequester(focusRequester), .focusRequester(focusRequester),
value = fieldValue, value = fieldValue,
onValueChange = { fieldValue = it }, onValueChange = { newVal ->
keyboardOptions = when (property.key.dataType.type) { fieldValue = if (property.key.params.digitsOnlyInput) {
DataProcessors.Type.INTEGER -> KeyboardOptions(keyboardType = KeyboardType.Number) val filtered = newVal.text.filter { ch -> ch.isDigit() }
DataProcessors.Type.FLOAT -> KeyboardOptions(keyboardType = KeyboardType.Decimal) 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) else -> KeyboardOptions(keyboardType = KeyboardType.Text)
}, },
singleLine = true, singleLine = true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -197,7 +197,7 @@
}, },
"sections": { "sections": {
"home": { "home": {
"version_title": "v{versionName} \u00b7 by ΞTΞRNAL", "version_title": "v{versionName} \u00b7 by \u039eT\u039eRNAL",
"update_title": "PurrfectSnap Update", "update_title": "PurrfectSnap Update",
"update_content": "Version {version} is available!", "update_content": "Version {version} is available!",
"update_button": "Download", "update_button": "Download",
@@ -247,9 +247,9 @@
"about_tagline": "An Xposed Module meant to enhance your Snapchat experience!", "about_tagline": "An Xposed Module meant to enhance your Snapchat experience!",
"about_lead_developers_title": "Lead Developers", "about_lead_developers_title": "Lead Developers",
"about_story_title": "Our Story", "about_story_title": "Our Story",
"about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by ΞTΞRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.", "about_story": "PurrfectSnap was founded on 2nd of October, 2025, as a fork of SnapEnhance by \u039eT\u039eRNAL with a vision to provide users the quality Snapchat experience they deserve. This app was just meant to be a minor update in the SnapEnhance repository, but it soon became a separate app wherein the contributors kept adding features. Then the developer <RSR/> joined the team, and this app soon became a huge success.\n\nWe would also like to convey our huge thanks to rhunk, the lead developer of SnapEnhance, as without him, this app wouldn't even exist. We are immensely grateful to him.\n\nWe received much love and support and gained 1K+ downloads in just two days! We thank all users and contributors; without your support, we wouldn't have reached this place.\n\nLastly, we would like to thank all our admins, notably: CLASSIC GENIUS, Harry, Sujal, Zain & schrodingerspet, who were right there with us from the very beginning. We would also like to thank all testers, notably Leo & Toxic, who tested and reported bugs continuously. We are immensely grateful for your contribution.",
"about_thanks_title": "With love, PurrfectSnap Team", "about_thanks_title": "With love, PurrfectSnap Team",
"about_magic_toast": "Tap 5 times in this screen to see some magic 😉!", "about_magic_toast": "Tap 5 times in this screen to see some magic \ud83d\ude09!",
"github_button": "GitHub", "github_button": "GitHub",
"telegram_button": "Telegram" "telegram_button": "Telegram"
}, },
@@ -377,21 +377,22 @@
"remove_all_tasks_confirm": "Remove all tasks?" "remove_all_tasks_confirm": "Remove all tasks?"
}, },
"features": { "features": {
"disabled": "Disabled", "disabled": "Disabled",
"export_option": "Export", "export_option": "Export",
"import_option": "Import", "import_option": "Import",
"reset_option": "Reset", "reset_option": "Reset",
"config_export_success_toast": "Config exported successfully", "config_export_success_toast": "Config exported successfully",
"config_import_success_toast": "Config imported successfully", "config_import_success_toast": "Config imported successfully",
"config_import_failure_toast": "Failed to import config {error}", "config_import_failure_toast": "Failed to import config {error}",
"config_export_failure_toast": "Failed to export config {error}", "config_export_failure_toast": "Failed to export config {error}",
"saved_config_snackbar": "Config saved", "saved_config_snackbar": "Config saved",
"older_required": "This feature requires Snapchat v{version} or older to work correctly", "older_required": "This feature requires Snapchat v{version} or older to work correctly",
"newer_required": "This feature requires Snapchat v{version} or newer to work correctly", "newer_required": "This feature requires Snapchat v{version} or newer to work correctly",
"search_button": "Search", "search_button": "Search",
"search_results_count": "{count} messages", "search_results_count": "{count} messages",
"clear_history": "Clear search history", "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": { "bypass_status": {
"active": "PurrAura Active", "active": "PurrAura Active",
@@ -1277,6 +1278,16 @@
"description": "The custom Snap Score you want to display (max 9,999,999)" "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)."
}
}
} }
} }
}, },
@@ -1534,7 +1545,10 @@
"name": "Bypass Message Action Restrictions", "name": "Bypass Message Action Restrictions",
"description": "Allows you to react to a snap without having opened it or to save an unsaveable message" "description": "Allows you to react to a snap without having opened it or to save an unsaveable message"
}, },
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." }, "pre_fetch_snaps": {
"name": "Snap Pre-Fetch",
"description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage."
},
"remove_groups_locked_status": { "remove_groups_locked_status": {
"name": "Remove Groups Locked Status", "name": "Remove Groups Locked Status",
"description": "Allows you to view group information after being kicked" "description": "Allows you to view group information after being kicked"
@@ -1755,9 +1769,12 @@
}, },
"thermal_protection": { "thermal_protection": {
"name": "Thermal Protection", "name": "Thermal Protection",
"description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40°C to prevent overheating" "description": "Automatically throttles the engine and increases delays if the device temperature exceeds 40\u00b0C to prevent overheating"
},
"only_on_wifi": {
"name": "Auto Open only on Wi-Fi",
"description": "Only process queue when connected to a Wi-Fi network to save mobile data"
}, },
"only_on_wifi": { "name": "Auto Open only on Wi-Fi", "description": "Only process queue when connected to a Wi-Fi network to save mobile data" },
"content_type_snap": "Snap", "content_type_snap": "Snap",
"only_when_idle": { "only_when_idle": {
"name": "Auto Open Schedule", "name": "Auto Open Schedule",
@@ -1767,41 +1784,9 @@
"name": "Auto Open Scheduler", "name": "Auto Open Scheduler",
"description": "Define the start and end times for scheduled throttled processing." "description": "Define the start and end times for scheduled throttled processing."
}, },
"safe_processing": { "name": "Auto Open with stealth pace", "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed." } "safe_processing": {
} "name": "Auto Open with stealth pace",
}, "description": "Adds variable delays and natural breaks to remain undetected. Turn off for maximum speed."
"pre_fetch_snaps": { "name": "Snap Pre-Fetch", "description": "Fetches snap media into Snapchat's internal cache once the snap arrives. Note: Turning it on increases background data usage." },
"instant_translation": {
"name": "Message Translator",
"description": "Configure the message translator"
},
"auto_delete_sent_messages": {
"name": "Auto Delete Sent Messages",
"description": "Automatically deletes sent messages after a specified time period",
"properties": {
"allow_running_in_background": {
"name": "Allow Running in Background",
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
},
"delete_after_value": {
"name": "Delete After (value)",
"description": "Time value before deleting the sent message"
},
"delete_after_unit": {
"name": "Time Unit",
"description": "Select the time unit for deletion delay"
},
"message_types": {
"name": "Message Types",
"description": "Select which message types should be auto-deleted"
},
"show_countdown": {
"name": "Show Countdown",
"description": "Show countdown before deleting the message"
},
"show_notification": {
"name": "Show Notification",
"description": "Show notification during countdown"
} }
} }
}, },
@@ -1859,6 +1844,36 @@
} }
} }
}, },
"auto_delete_sent_messages": {
"name": "Auto Delete Sent Messages",
"description": "Automatically deletes sent messages after a specified time period",
"properties": {
"allow_running_in_background": {
"name": "Allow Running in Background",
"description": "Allows Auto Delete Sent Messages to run in the background. Note: This will significantly drain your battery"
},
"delete_after_value": {
"name": "Delete After (value)",
"description": "Time value before deleting the sent message"
},
"delete_after_unit": {
"name": "Time Unit",
"description": "Select the time unit for deletion delay"
},
"message_types": {
"name": "Message Types",
"description": "Select which message types should be auto-deleted"
},
"show_countdown": {
"name": "Show Countdown",
"description": "Show countdown before deleting the message"
},
"show_notification": {
"name": "Show Notification",
"description": "Show notification during countdown"
}
}
},
"scheduled_send_allow_running_in_background": { "scheduled_send_allow_running_in_background": {
"name": "Allow Scheduled Send to Run in Background", "name": "Allow Scheduled Send to Run in Background",
"description": "Keep scheduled messages processing while Snapchat is in the background" "description": "Keep scheduled messages processing while Snapchat is in the background"
@@ -2144,7 +2159,15 @@
"name": "HEVC Recording", "name": "HEVC Recording",
"description": "Uses HEVC (H.265) codec for video recording" "description": "Uses HEVC (H.265) codec for video recording"
}, },
"camera_tweaks": { "name": "Upgraded Camera Engine", "description": "Enables professional hardware ISP processing modes for better dynamic range" }, "audio_video": { "name": "Upgraded Audio and Video", "description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz" }, "video_record_timer": { "camera_tweaks": {
"name": "Upgraded Camera Engine",
"description": "Enables professional hardware ISP processing modes for better dynamic range"
},
"audio_video": {
"name": "Upgraded Audio and Video",
"description": "Increases Video bitrate to 30Mbps and Audio to 320kbps/48kHz"
},
"video_record_timer": {
"name": "Video Recording Timer", "name": "Video Recording Timer",
"description": "Shows a recording timer overlay when recording video" "description": "Shows a recording timer overlay when recording video"
}, },
@@ -2714,7 +2737,11 @@
} }
} }
}, },
"network_optimization": { "name": "Improved Network Connectivity", "description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance" }, "better_transcript": { "network_optimization": {
"name": "Improved Network Connectivity",
"description": "Optimizes network socket buffers for maximum stability and high-speed upload/download performance"
},
"better_transcript": {
"name": "Better Transcript", "name": "Better Transcript",
"description": "Improves the voice note transcript", "description": "Improves the voice note transcript",
"properties": { "properties": {
@@ -3843,7 +3870,12 @@
"export_failed_toast": "Failed to export account. Check logs for more info.", "export_failed_toast": "Failed to export account. Check logs for more info.",
"forced_logout_toast": "Removed account due to forced logout" "forced_logout_toast": "Removed account due to forced logout"
}, },
"auto_open_snaps": { "title": "Auto Open Snaps", "processed_count": "Opened", "queue_size": "Queue", "action_reset": "Reset Statistics", "priority_title": "Auto Open Snaps (Priority)", "auto_open_snaps": {
"title": "Auto Open Snaps",
"processed_count": "Opened",
"queue_size": "Queue",
"action_reset": "Reset Count",
"priority_title": "Auto Open Snaps (Priority)",
"auto_open_schedule": { "auto_open_schedule": {
"title": "Auto Open Scheduler", "title": "Auto Open Scheduler",
"start": "Start", "start": "Start",
@@ -3860,7 +3892,6 @@
"action_pause": "Pause", "action_pause": "Pause",
"action_resume": "Resume", "action_resume": "Resume",
"action_clear": "Clear Queue", "action_clear": "Clear Queue",
"action_reset": "Reset Count",
"error_content": "Failed to open snap from {sender}: {error}", "error_content": "Failed to open snap from {sender}: {error}",
"resumed_feedback": "Auto Open Resumed", "resumed_feedback": "Auto Open Resumed",
"paused_feedback": "Auto Open Paused", "paused_feedback": "Auto Open Paused",
@@ -3877,9 +3908,9 @@
"speed_throttled": "Throttled", "speed_throttled": "Throttled",
"estimated_time": "Estimated Time", "estimated_time": "Estimated Time",
"notification_statistics": "STATISTICS", "notification_statistics": "STATISTICS",
"notification_total_opened": "Lifetime Opened", "notification_total_opened": "Total Snaps Opened",
"notification_queue_preview": "QUEUE PREVIEW", "notification_queue_preview": "QUEUE PREVIEW",
"notification_no_snaps_queue": "Monitoring snaps in background...", "notification_no_snaps_queue": "No snaps in queue.",
"queue_cleared": "Queue cleared and statistics reset", "queue_cleared": "Queue cleared and statistics reset",
"queue_cleared_title": "Queue cleared", "queue_cleared_title": "Queue cleared",
"queue_cleared_reset": "Queue Cleared & Reset", "queue_cleared_reset": "Queue Cleared & Reset",
@@ -3894,12 +3925,8 @@
"conversation_type_group_chat": "Group Chat", "conversation_type_group_chat": "Group Chat",
"conversation_type_chat": "Chat", "conversation_type_chat": "Chat",
"notification_status": "Status", "notification_status": "Status",
"notification_statistics": "STATISTICS",
"notification_queue_size": "Queue Size", "notification_queue_size": "Queue Size",
"notification_total_opened": "Total Snaps Opened",
"notification_queue_preview": "QUEUE PREVIEW",
"notification_processing_continue": "Processing will continue automatically...", "notification_processing_continue": "Processing will continue automatically...",
"notification_no_snaps_queue": "No snaps in queue.",
"notification_queue_cleared_opened": "Queue cleared ({opened} opened)", "notification_queue_cleared_opened": "Queue cleared ({opened} opened)",
"content_type_photo_video_snap": "Photo/Video Snap", "content_type_photo_video_snap": "Photo/Video Snap",
"conversation_type_group_with_name": "Group: {name}", "conversation_type_group_with_name": "Group: {name}",
@@ -4027,7 +4054,7 @@
"username": "Username", "username": "Username",
"user_id": "User ID", "user_id": "User ID",
"posted_on": "Posted", "posted_on": "Posted",
"loading_username": "Loading", "loading_username": "Loading\u2026",
"username_copied": "Username copied", "username_copied": "Username copied",
"user_id_copied": "User ID copied", "user_id_copied": "User ID copied",
"friend_status": "Friend status", "friend_status": "Friend status",
@@ -4112,10 +4139,10 @@
"search": { "search": {
"placeholder": "Search" "placeholder": "Search"
}, },
"filters": { "filters": {
"newest_first": "Newest first", "newest_first": "Newest first",
"pick_a_date": "Pick a date", "pick_a_date": "Pick a date",
"title": "Filters", "title": "Filters",
"search_by": "Search by", "search_by": "Search by",
"since": "Since", "since": "Since",
"until": "Until", "until": "Until",
@@ -4322,8 +4349,6 @@
"added": "Added", "added": "Added",
"no_friends_found": "No friends found", "no_friends_found": "No friends found",
"no_messages": "No messages", "no_messages": "No messages",
"message": "Message",
"type_message": "Type message...",
"exporting_memories": "Exporting memories... ({failed} failed)" "exporting_memories": "Exporting memories... ({failed} failed)"
}, },
"clear_friend_feed": "Clear Friend Feed", "clear_friend_feed": "Clear Friend Feed",
@@ -4468,4 +4493,4 @@
"tasks_remove_all_tasks_title": "Are you sure you want to remove all tasks?", "tasks_remove_all_tasks_title": "Are you sure you want to remove all tasks?",
"tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?", "tasks_remove_selected_tasks_confirm": "Remove {count} selected tasks?",
"tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history." "tasks_remove_all_tasks_confirm": "This will stop all running tasks and clear the history."
} }

View File

@@ -81,6 +81,7 @@ class ConfigParams(
var inputCheck: ((String) -> Boolean)? = { true }, var inputCheck: ((String) -> Boolean)? = { true },
var filenameFilter: ((String) -> Boolean)? = null, var filenameFilter: ((String) -> Boolean)? = null,
var versionCheck: VersionCheck? = 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 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() val flags get() = _flags?.let { ConfigFlag.entries.filter { flag -> it and flag.id != 0 } } ?: emptyList()

View File

@@ -77,4 +77,14 @@ class UserInterfaceTweaks : ConfigContainer() {
} }
val spoofSnapScore = container("spoof_snap_score", SpoofSnapScore()) { requireRestart() } 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

@@ -300,6 +300,8 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event -> this@AutoOpenSnaps.context.event.subscribe(BuildMessageEvent::class, priority = 103) { event ->
if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe if (autoOpenConfig.globalState == false || !engineActive.get()) return@subscribe
val message = event.message val message = event.message
// 1. Basic Filters & Self-Check
if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe if (message.messageState != MessageState.COMMITTED || message.senderId?.toString() == this@AutoOpenSnaps.context.database.myUserId) return@subscribe
val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe val clientMessageId = message.messageDescriptor?.messageId ?: return@subscribe
@@ -310,8 +312,20 @@ class AutoOpenSnaps: MessagingRuleFeature("Auto Open Snaps", MessagingRuleType.A
if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe if (contentType != ContentType.SNAP && contentType != ContentType.EXTERNAL_MEDIA) return@subscribe
if (!canUseRule(conversationId)) return@subscribe if (!canUseRule(conversationId)) return@subscribe
// Prevent re-queueing the same message while it is currently being processed // 2. Memory Gating: Prevent processing the same session snap multiple times
if (openedSnapsIds.contains(clientMessageId)) return@subscribe if (openedSnapsIds.contains(clientMessageId)) return@subscribe
// 3. Database Authority: Immediate check to see if snap is already opened
val dbMessage = this@AutoOpenSnaps.context.database.getConversationMessageFromId(clientMessageId)
if (dbMessage?.isViewedByUser == 1) return@subscribe
// 4. Temporal Gating: Ignore ancient unread snaps (fixes 'Ghost Storm' during sync)
val now = System.currentTimeMillis()
val messageTime = message.messageMetadata?.createdAt ?: 0L
if (now - messageTime > 28_800_000L) { // 8-hour window
return@subscribe
}
openedSnapsIds.add(clientMessageId) openedSnapsIds.add(clientMessageId)
val senderId = message.senderId?.toString() ?: "unknown" val senderId = message.senderId?.toString() ?: "unknown"

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"),
)
}
}

View File

@@ -13,6 +13,7 @@ import me.eternal.purrfectsnap.core.util.ktx.getObjectField
import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID import me.eternal.purrfectsnap.core.wrapper.impl.SnapUUID
import me.eternal.purrfectsnap.mapper.impl.CallbackMapper import me.eternal.purrfectsnap.mapper.impl.CallbackMapper
import java.util.ArrayList import java.util.ArrayList
import java.util.concurrent.ConcurrentHashMap
class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) { class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType = MessagingRuleType.HIDE_FRIEND_FEED) {
@Volatile @Volatile
@@ -21,6 +22,10 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
@Volatile @Volatile
private var cachedRuleIdsAt = 0L private var cachedRuleIdsAt = 0L
private val conversationTargetsCache = ConcurrentHashMap<String, Set<String>>()
private val hideDecisionCache = ConcurrentHashMap<String, Boolean>()
private var lastRuleIdsHash = 0
private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder { private fun createDeletedFeedEntry(conversationIdInstance: Any) = findClass("com.snapchat.client.messaging.DeletedFeedEntry").dataBuilder {
from("mFeedEntryIdentifier") { from("mFeedEntryIdentifier") {
set("mConversationId", conversationIdInstance) set("mConversationId", conversationIdInstance)
@@ -39,13 +44,15 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
} }
private fun resolveRuleTargets(conversationId: String): Set<String> { private fun resolveRuleTargets(conversationId: String): Set<String> {
val targets = linkedSetOf(conversationId) return conversationTargetsCache.getOrPut(conversationId) {
context.database.getDMOtherParticipant(conversationId)?.let { targets.add(it) } val targets = linkedSetOf(conversationId)
context.database.getFeedEntryByConversationId(conversationId)?.let { entry -> context.database.getDMOtherParticipant(conversationId)?.let { targets.add(it) }
entry.friendUserId?.let { targets.add(it) } context.database.getFeedEntryByConversationId(conversationId)?.let { entry ->
entry.participants?.forEach { targets.add(it) } entry.friendUserId?.let { targets.add(it) }
entry.participants?.forEach { targets.add(it) }
}
targets
} }
return targets
} }
private fun shouldHideConversation( private fun shouldHideConversation(
@@ -54,8 +61,18 @@ class HideFriendFeedEntry : MessagingRuleFeature("HideFriendFeedEntry", ruleType
ruleState: RuleState? ruleState: RuleState?
): Boolean { ): Boolean {
if (ruleState == null) return false if (ruleState == null) return false
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
return if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch // Industrial Cache Gating: Clear decisions if the master rule list changed
val currentHash = ruleIds.hashCode()
if (currentHash != lastRuleIdsHash) {
hideDecisionCache.clear()
lastRuleIdsHash = currentHash
}
return hideDecisionCache.getOrPut(conversationId) {
val isExplicitRuleMatch = resolveRuleTargets(conversationId).any { it in ruleIds }
if (ruleState == RuleState.BLACKLIST) !isExplicitRuleMatch else isExplicitRuleMatch
}
} }
private fun filterFriendFeed( private fun filterFriendFeed(