feat: Initial commit!

This commit is contained in:
ΞTΞRNAL
2025-10-03 17:10:09 +05:30
parent b720b58886
commit 0676212a38
279 changed files with 449644 additions and 2215 deletions

View File

@@ -2,20 +2,34 @@ package me.rhunk.snapenhance.core
import android.system.Os
import android.view.ViewGroup
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.rounded.NotInterested
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
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.rhunk.snapenhance.common.bridge.FileHandleScope
import me.rhunk.snapenhance.common.bridge.toWrapper
@@ -48,9 +62,52 @@ class SecurityFeatures(
transact(this, 0)?.toString(2)?.padStart(32, '0')?.count { it == '1' }
}
private fun showBypassStatusIndicator(isWorking: Boolean) {
if (context.bridgeClient.getDebugProp("disable_bypass_indicator", "false") == "true") {
return
}
lateinit var composable: CustomComposable
composable = {
Row(
modifier = Modifier
.padding(16.dp)
.align(Alignment.TopCenter)
.offset(y = (-8).dp)
.background(
color = Color.Black.copy(alpha = 0.8f),
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isWorking) Icons.Filled.Check else Icons.Filled.Close,
contentDescription = null,
tint = if (isWorking) Color.Green else Color.Red,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (isWorking) "Bypass Active" else "Bypass Inactive",
color = Color.White,
fontSize = 14.sp
)
}
LaunchedEffect(Unit) {
delay(3000)
context.inAppOverlay.removeCustomComposable(composable)
}
}
context.inAppOverlay.addCustomComposable(composable)
}
fun init() {
val snapchatVersionCode = context.androidContext.packageManager?.getPackageInfo(context.androidContext.packageName, 0)?.longVersionCode ?: throw IllegalStateException("Failed to get version code")
var shouldDisablePlugin = MOD_DETECTION_VERSION_CHECK.checkVersion(snapchatVersionCode)?.second == VersionRequirement.OLDER_REQUIRED
var usingCustomSharedLibrary = false
// load user shared library
context.config.experimental.nativeHooks.customSharedLibrary.get().takeIf { it.isNotEmpty() }?.let {
@@ -60,6 +117,7 @@ class SecurityFeatures(
)
context.log.verbose("loaded custom shared library")
shouldDisablePlugin = false
usingCustomSharedLibrary = true
lateinit var composable: CustomComposable
composable = {
@@ -89,6 +147,11 @@ class SecurityFeatures(
context.disablePlugin = shouldDisablePlugin
context.log.verbose("disablePlugin=${context.disablePlugin}")
// Show bypass status indicator only when not using custom shared library
if (!usingCustomSharedLibrary) {
showBypassStatusIndicator(context.disablePlugin)
}
if (!context.disablePlugin) return
val allowedEPs = listOf(
@@ -212,4 +275,4 @@ class SecurityFeatures(
}
}
}
}
}

View File

@@ -139,7 +139,7 @@ class SnapEnhance {
isBridgeInitialized = true
}.onFailure {
appContext.logCritical("Failed to initialize bridge", it)
InAppOverlay.showCrashOverlay("SnapEnhance failed to initialize. Please check logs for more details.", it)
InAppOverlay.showCrashOverlay("PurrfectSnap failed to initialize. Please check logs for more details.", it)
}
}
}

View File

@@ -69,7 +69,6 @@ class BulkMessagingAction : AbstractAction() {
MOST_RECENT_MESSAGE,
NEAREST_LOCATION
}
enum class Filter {
ALL,
MY_FRIENDS,
@@ -82,10 +81,8 @@ class BulkMessagingAction : AbstractAction() {
NON_STREAKS,
LOCATION_ON_MAP
}
private val translation by lazy { context.translation.getCategory("bulk_messaging_action") }
private val betterLocation by lazy { context.feature(BetterLocation::class) }
private fun removeAction(
ctx: Context,
ids: List<String>,
@@ -108,7 +105,6 @@ class BulkMessagingAction : AbstractAction() {
.setCancelable(false)
.show()
}
ids.forEachIndexed { index, id ->
launch(Dispatchers.Main) {
dialog.setTitle(
@@ -131,7 +127,6 @@ class BulkMessagingAction : AbstractAction() {
dialog.dismiss()
}
}
@Composable
private fun ConfirmationDialog(
onConfirm: () -> Unit,
@@ -153,25 +148,24 @@ class BulkMessagingAction : AbstractAction() {
}
)
}
private fun filterFriends(friends: List<FriendInfo>, filter: Filter, nameFilter: String): List<FriendInfo> {
val userIdBlacklist = arrayOf(
context.database.myUserId,
"b42f1f70-5a8b-4c53-8c25-34e7ec9e6781", // myai
"84ee8839-3911-492d-8b94-72dd80f3713a", // teamsnapchat
)
return friends.filter { friend ->
// FIX: add safe call, as userIdBlacklist could be nullable
friend.userId !in userIdBlacklist && when (filter) {
Filter.ALL -> true
Filter.MY_FRIENDS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0
Filter.BLOCKED -> friend.friendLinkType == FriendLinkType.BLOCKED.value
Filter.REMOVED_ME -> friend.friendLinkType == FriendLinkType.OUTGOING.value && friend.addedTimestamp > 0 && friend.businessCategory == 0 // ignore followed accounts
Filter.REMOVED_ME -> friend.friendLinkType == FriendLinkType.OUTGOING.value && friend.addedTimestamp > 0 && friend.businessCategory == 0
Filter.SUGGESTED -> friend.friendLinkType == FriendLinkType.SUGGESTED.value
Filter.DELETED -> friend.friendLinkType == FriendLinkType.DELETED.value
Filter.BUSINESS_ACCOUNTS -> friend.businessCategory > 0
Filter.STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0 && friend.streakLength != 0
Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value&& friend.addedTimestamp > 0 && friend.streakLength == 0
Filter.NON_STREAKS -> friend.friendLinkType == FriendLinkType.MUTUAL.value && friend.addedTimestamp > 0 && friend.streakLength == 0
Filter.LOCATION_ON_MAP -> betterLocation.locationHistory.contains(friend.userId)
} && nameFilter.takeIf { it.isNotBlank() }?.let { name ->
friend.mutableUsername?.contains(
@@ -181,13 +175,11 @@ class BulkMessagingAction : AbstractAction() {
} ?: true
}
}
private fun getDMLastMessage(userId: String?): ConversationMessage? {
return context.database.getDMConversationId(userId ?: return null)?.let {
context.database.getMessagesFromConversationId(it, 1)
}?.firstOrNull()
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun BulkMessagingDialog() {
@@ -199,13 +191,10 @@ class BulkMessagingAction : AbstractAction() {
val friends = remember { mutableStateListOf<FriendInfo>() }
val bitmojiCache = remember { EvictingMap<String, Bitmap>(50) }
val noBitmojiBitmap = remember { BitmapFactory.decodeResource(context.resources, android.R.drawable.ic_menu_report_image).asImageBitmap() }
val focusManager = LocalFocusManager.current
var nameFilter by remember { mutableStateOf("") }
suspend fun refreshList(clearSelected: Boolean = true) {
val myLocation = betterLocation.locationHistory[context.database.myUserId]
withContext(Dispatchers.IO) {
val newFriends = context.database.getAllFriends().let { friends ->
filterFriends(friends, filter, nameFilter)
@@ -239,7 +228,6 @@ class BulkMessagingAction : AbstractAction() {
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -252,7 +240,6 @@ class BulkMessagingAction : AbstractAction() {
verticalAlignment = Alignment.CenterVertically
) {
var filterMenuExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = filterMenuExpanded,
onExpandedChange = { filterMenuExpanded = it },
@@ -262,7 +249,6 @@ class BulkMessagingAction : AbstractAction() {
) {
Text(text = filter.name, modifier = Modifier.padding(5.dp))
}
DropdownMenu(
expanded = filterMenuExpanded,
onDismissRequest = { filterMenuExpanded = false }
@@ -277,9 +263,7 @@ class BulkMessagingAction : AbstractAction() {
}
}
}
var sortMenuExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = sortMenuExpanded,
onExpandedChange = { sortMenuExpanded = it },
@@ -289,7 +273,6 @@ class BulkMessagingAction : AbstractAction() {
) {
Text(text = "Sort by", modifier = Modifier.padding(5.dp))
}
DropdownMenu(
expanded = sortMenuExpanded,
onDismissRequest = { sortMenuExpanded = false }
@@ -304,7 +287,6 @@ class BulkMessagingAction : AbstractAction() {
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
@@ -315,7 +297,6 @@ class BulkMessagingAction : AbstractAction() {
Text(text = "Reverse order", fontSize = 15.sp, fontWeight = FontWeight.Light, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
@@ -346,7 +327,6 @@ class BulkMessagingAction : AbstractAction() {
unfocusedContainerColor = Color.Transparent
),
)
Checkbox(
checked = if (friends.isEmpty() || selectedFriends.size < friends.size) false else friends.all { friend -> selectedFriends.contains(friend.userId) },
onCheckedChange = { state ->
@@ -376,7 +356,6 @@ class BulkMessagingAction : AbstractAction() {
}
items(friends, key = { it.userId!! }) { friendInfo ->
var bitmojiBitmap by remember(friendInfo) { mutableStateOf(bitmojiCache[friendInfo.bitmojiAvatarId]) }
fun selectFriend(state: Boolean) {
friendInfo.userId?.let {
if (state) {
@@ -386,13 +365,13 @@ class BulkMessagingAction : AbstractAction() {
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
selectFriend(!selectedFriends.contains(friendInfo.userId))
}.pointerInput(Unit) {
}
.pointerInput(Unit) {
detectTapGestures(
onLongPress = { context.androidContext.copyToClipboard(friendInfo.mutableUsername.toString()) }
)
@@ -403,11 +382,9 @@ class BulkMessagingAction : AbstractAction() {
LaunchedEffect(friendInfo) {
withContext(Dispatchers.IO) {
if (bitmojiBitmap != null || friendInfo.bitmojiAvatarId == null || friendInfo.bitmojiSelfieId == null) return@withContext
val bitmojiUrl = BitmojiSelfie.getBitmojiSelfie(friendInfo.bitmojiSelfieId, friendInfo.bitmojiAvatarId, BitmojiSelfie.BitmojiSelfieType.NEW_THREE_D) ?: return@withContext
runCatching {
RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, length ->
RemoteMediaResolver.downloadMedia(bitmojiUrl) { inputStream, _ ->
bitmojiCache[friendInfo.bitmojiAvatarId ?: return@withContext] = BitmapFactory.decodeStream(inputStream).also {
bitmojiBitmap = it
}
@@ -415,13 +392,11 @@ class BulkMessagingAction : AbstractAction() {
}
}
}
Image(
bitmap = remember (bitmojiBitmap) { bitmojiBitmap?.asImageBitmap() ?: noBitmojiBitmap },
contentDescription = null,
modifier = Modifier.size(35.dp)
)
Column(
modifier = Modifier.weight(1f),
) {
@@ -436,7 +411,6 @@ class BulkMessagingAction : AbstractAction() {
val lastMessage by rememberAsyncMutableState(defaultValue = null) {
getDMLastMessage(friendInfo.userId)
}
val userInfo = remember(friendInfo, lastMessage) {
buildString {
append("Relationship: ")
@@ -465,7 +439,6 @@ class BulkMessagingAction : AbstractAction() {
}
Text(text = userInfo, fontSize = 12.sp, fontWeight = FontWeight.Light, lineHeight = 12.sp, overflow = TextOverflow.Ellipsis)
}
Checkbox(
checked = selectedFriends.contains(friendInfo.userId),
onCheckedChange = { selectFriend(it) }
@@ -473,10 +446,8 @@ class BulkMessagingAction : AbstractAction() {
}
}
}
var showConfirmationDialog by remember { mutableStateOf(false) }
var action by remember { mutableStateOf({}) }
if (showConfirmationDialog) {
ConfirmationDialog(
onConfirm = {
@@ -490,9 +461,7 @@ class BulkMessagingAction : AbstractAction() {
}
)
}
val ctx = LocalContext.current
val actions = remember {
mapOf<() -> String, () -> Unit>(
{ "Clean " + selectedFriends.size + " conversations" } to {
@@ -535,7 +504,6 @@ class BulkMessagingAction : AbstractAction() {
}
)
}
Column(
modifier = Modifier.fillMaxWidth(),
) {
@@ -555,14 +523,12 @@ class BulkMessagingAction : AbstractAction() {
}
}
}
LaunchedEffect(sortBy, sortReverseOrder) {
coroutineScope.launch {
refreshList(clearSelected = false)
}
focusManager.clearFocus()
}
LaunchedEffect(filter) {
coroutineScope.launch {
refreshList()
@@ -570,7 +536,6 @@ class BulkMessagingAction : AbstractAction() {
focusManager.clearFocus()
}
}
override fun run() {
context.coroutineScope.launch(Dispatchers.Main) {
createComposeAlertDialog(context.mainActivity!!) {
@@ -581,40 +546,89 @@ class BulkMessagingAction : AbstractAction() {
}
}
}
private fun removeFriend(userId: String) {
context.mappings.useMapper(FriendRelationshipChangerMapper::class) {
val friendRelationshipChangerInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance!!
val runFriendDurableJobMethod = classReference.getAsClass()?.methods?.first {
it.name == runFriendDurableJob.getAsString()
} ?: throw Exception("Failed to find runFriendDurableJobMethod method")
val removeFriendDurableJob = context.androidContext.classLoader.loadClass("com.snap.identity.job.snapchatter.RemoveFriendDurableJob")
.constructors.firstOrNull {
it.parameterTypes.size == 1
}?.run {
newInstance(
parameterTypes[0].dataBuilder {
set("a", userId) // userId
set("b", "DELETED_BY_MY_FRIENDS") // deleteSourceType
set("f", "")
try {
context.log.info("removeFriend started for $userId")
val addFriendSpoofInstance = context.feature(AddFriendSourceSpoof::class).friendRelationshipChangerInstance
context.log.info("addFriendSpoofInstance is: ${addFriendSpoofInstance?.javaClass?.name}")
val friendRelationshipChangerInstance: Any = addFriendSpoofInstance ?: run {
val clazz = classReference.get()
?: throw Exception("FriendRelationshipChanger class not found")
context.log.info("FriendRelationshipChanger constructors: " + clazz.constructors.joinToString { it.toString() })
when {
clazz.constructors.any { it.parameterTypes.isEmpty() } -> clazz.constructors.first { it.parameterTypes.isEmpty() }.newInstance()
clazz.constructors.any { it.parameterTypes.size == 1 } -> clazz.constructors.first { it.parameterTypes.size == 1 }.newInstance(context.mainActivity)
clazz.constructors.any { it.parameterTypes.size == 2 } -> clazz.constructors.first { it.parameterTypes.size == 2 }.newInstance(context.mainActivity, context.mainActivity!!.application)
else -> throw Exception("No suitable FriendRelationshipChanger constructor found")
}
)
} ?: throw Exception("Failed to create RemoveFriendDurableJob instance")
val completable = runFriendDurableJobMethod.invoke(null,
friendRelationshipChangerInstance,
userId, // userId
removeFriendDurableJob, // friend durable job
0x5, // action type
"DELETED_BY_MY_FRIENDS", // deleteSourceType
)!!
completable::class.java.methods.first {
it.name == "subscribe" && it.parameterTypes.isEmpty()
}.invoke(completable)
}
context.log.info("Obtained instance: ${friendRelationshipChangerInstance.javaClass.name}")
val method = friendRelationshipChangerInstance.javaClass.methods.firstOrNull {
it.parameterTypes.size == 5
} ?: throw Exception("Failed to find a suitable method for remove friend. Please contact support.")
context.log.info("Target method found: ${method.name}")
val enumClass = method.parameterTypes[1]
val enumConstants = enumClass.enumConstants ?: throw Exception("Could not get enum constants from ${enumClass.name}")
val deletedByMyFriends = enumConstants.firstOrNull {
it.toString() == "DELETED_BY_MY_FRIENDS"
} ?: enumConstants.first()
context.log.info("Enum resolved: $deletedByMyFriends")
val c36077qT8Class = method.parameterTypes[4]
val constructor = c36077qT8Class.constructors.firstOrNull {
it.parameterTypes.size == 2 && it.parameterTypes.all { p -> p == String::class.java }
} ?: throw Exception("Failed to find suitable constructor for C36077qT8")
val placementInfo = constructor.newInstance("", "")
context.log.info("C36077qT8 instance created: $placementInfo")
val completable = method.invoke(
friendRelationshipChangerInstance,
userId,
deletedByMyFriends,
"",
"",
placementInfo
) ?: throw Exception("Friend removal call returned null.")
context.log.info("Completable: $completable")
val completableClass = completable::class.java
val allMethods = completableClass.methods.joinToString("\n") { it.toString() }
context.log.info("Completable class: ${completableClass.name}")
context.log.info("All Completable methods:\n$allMethods")
val vMethod = completableClass.methods.firstOrNull { it.name == "V" && it.parameterTypes.size == 1 }
if (vMethod != null) {
context.log.info("Found V(hq3), creating dynamic proxy by parameter type...")
val hq3Class = vMethod.parameterTypes[0]
val proxy = java.lang.reflect.Proxy.newProxyInstance(
hq3Class.classLoader,
arrayOf(hq3Class)
) { _, _, _ -> }
context.log.info("Invoking V(hq3) with dynamic proxy")
vMethod.invoke(completable, proxy)
context.log.info("removeFriend triggered with V(hq3) and proxy")
} else {
val bMethod = completableClass.methods.firstOrNull { it.name == "b" && it.parameterTypes.size == 1 }
if (bMethod != null) {
val hq3Class = bMethod.parameterTypes[0]
val proxy = java.lang.reflect.Proxy.newProxyInstance(
hq3Class.classLoader,
arrayOf(hq3Class)
) { _, _, _ -> }
context.log.info("Invoking b(hq3) with dynamic proxy")
bMethod.invoke(completable, proxy)
context.log.info("removeFriend triggered with b(hq3) and proxy")
} else {
val triggerMethods = completableClass.methods.filter { it.returnType == Void.TYPE && it.parameterTypes.size == 1 }
context.log.error("No trigger method found. 1-arg void methods: ${triggerMethods.joinToString { it.toString() }}")
throw IllegalArgumentException("No trigger method found on Completable.")
}
}
} catch (e: Exception) {
context.log.error("removeFriend failed", e)
println("removeFriend failed: " + e + "\n" + e.stackTraceToString())
context.shortToast("removeFriend failed: ${e.message}")
throw e
}
}
}
private suspend fun cleanConversation(
conversationId: String,
setDialogMessage: (String) -> Unit

View File

@@ -144,6 +144,7 @@ class FeatureManager(
SnapScoreChanges(),
DisableSnapModeRestrictions(),
PreventForcedKeyboard(),
CustomTheming(),
)
features.values.toList().forEach { feature ->

View File

@@ -5,18 +5,6 @@ import android.location.LocationManager
import android.view.View
import android.view.ViewGroup
import android.widget.RelativeLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditLocation
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import me.rhunk.snapenhance.common.ui.OverlayType
import me.rhunk.snapenhance.common.ui.createComposeView
import me.rhunk.snapenhance.common.util.protobuf.EditorContext
@@ -54,26 +42,21 @@ data class FriendLocation(
fun distanceTo(other: FriendLocation): Double {
val deltaLat = Math.toRadians(other.latitude - this.latitude)
val deltaLong = Math.toRadians(other.longitude - this.longitude)
val a = sin(deltaLat / 2) * sin(deltaLat / 2) +
cos(Math.toRadians(this.latitude)) * cos(Math.toRadians(other.latitude)) *
sin(deltaLong / 2) * sin(deltaLong / 2)
return 6371 * 2 * atan2(sqrt(a), sqrt(1 - a))
}
}
class BetterLocation : Feature("Better Location") {
val locationHistory = mutableMapOf<String, FriendLocation>()
private val walkRadius by lazy {
context.config.global.betterLocation.walkRadius.getNullable()
}
private val randomWalking by lazy {
RandomWalking(walkRadius?.toDoubleOrNull())
}
private fun getLat() : Double {
var spoofedLatitude = context.config.global.betterLocation.coordinates.get().first
walkRadius?.let {
@@ -81,7 +64,6 @@ class BetterLocation : Feature("Better Location") {
}
return spoofedLatitude
}
private fun getLong() : Double {
var spoofedLongitude = context.config.global.betterLocation.coordinates.get().second
walkRadius?.let {
@@ -89,10 +71,8 @@ class BetterLocation : Feature("Better Location") {
}
return spoofedLongitude
}
private fun editClientUpdate(editor: EditorContext) {
val config = context.config.global.betterLocation
editor.apply {
// SCVSLocationUpdate
edit(1) {
@@ -103,17 +83,14 @@ class BetterLocation : Feature("Better Location") {
addFixed32(1, getLat().toFloat()) // lat
addFixed32(2, getLong().toFloat()) // lng
}
if (config.alwaysUpdateLocation.get()) {
remove(7)
addVarInt(7, System.currentTimeMillis()) // timestamp
}
}
if (context.config.global.betterLocation.suspendLocationUpdates.get()) {
remove(1)
}
// SCVSDeviceData
edit(3) {
config.spoofBatteryLevel.getNullable()?.takeIf { it.isNotEmpty() }?.let {
@@ -125,14 +102,12 @@ class BetterLocation : Feature("Better Location") {
addVarInt(3, 1) // devicePluggedIn
}
}
if (config.spoofHeadphones.get()) {
remove(4)
addVarInt(4, 1) // headphoneOutput
remove(6)
addVarInt(6, 1) // isOtherAudioPlaying
}
edit(10) {
remove(1)
addVarInt(1, 4) // type = ALWAYS
@@ -142,14 +117,11 @@ class BetterLocation : Feature("Better Location") {
}
}
}
private fun onLocationEvent(protoReader: ProtoReader) {
protoReader.eachBuffer(3, 1) {
val clusterId = UUID(getFixed64(1, 1) ?: return@eachBuffer, getFixed64(1, 2) ?: return@eachBuffer).toString()
val latitude = getFixed32(4)?.let { Float.fromBits(it) }?.toDouble() ?: return@eachBuffer
val longitude = getFixed32(5)?.let { Float.fromBits(it) }?.toDouble() ?: return@eachBuffer
val locality = getString(10)
val localityPieces = mutableListOf<String>().also {
forEach { index, wire ->
@@ -157,7 +129,6 @@ class BetterLocation : Feature("Better Location") {
it.add((wire.value as ByteArray).toString(Charsets.UTF_8) )
}
}
eachBuffer(7) friend@{
val userId = if (contains(1)) UUID(getFixed64(1, 1) ?: return@friend, getFixed64(1, 2) ?: return@friend).toString() else clusterId
val friendLocation = FriendLocation(
@@ -169,17 +140,14 @@ class BetterLocation : Feature("Better Location") {
localityPieces = localityPieces,
batteryLevel = getFixed32(13)?.let { Float.fromBits(it) } ?: -1F,
)
locationHistory[userId] = friendLocation
}
}
}
private fun openManagementOverlay() {
context.bridgeClient.getLocationManager().provideFriendsLocation(
locationHistory.values.toList().mapNotNull { locationHistory ->
val friendInfo = context.database.getFriendInfo(locationHistory.userId) ?: return@mapNotNull null
me.rhunk.snapenhance.bridge.location.FriendLocation().also {
it.username = friendInfo.mutableUsername ?: return@mapNotNull null
it.displayName = friendInfo.displayName
@@ -195,12 +163,9 @@ class BetterLocation : Feature("Better Location") {
)
context.bridgeClient.openOverlay(OverlayType.BETTER_LOCATION)
}
override fun init() {
if (context.config.global.betterLocation.globalState != true) return
val canSpoofLocation = { context.config.global.betterLocation.spoofLocation.get() }
LocationManager::class.java.apply {
hook("isProviderEnabled", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(true) }
hook("isProviderEnabledForUser", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(true) }
@@ -209,9 +174,7 @@ class BetterLocation : Feature("Better Location") {
hook("getLatitude", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(getLat()) }
hook("getLongitude", HookStage.BEFORE, { canSpoofLocation() }) { it.setResult(getLong()) }
}
val mapViewId = context.resources.getId("mapview")
if (context.config.global.betterLocation.showBatteryLevel.get()) {
findClass("snap.snap_maps_sdk.nano.SnapMapsSdk\$PublicUserInfo").hook("setDisplayName", HookStage.BEFORE) { param ->
val instance = param.thisObject<Any>()
@@ -219,55 +182,26 @@ class BetterLocation : Feature("Better Location") {
val batteryLevel = locationHistory[userId]?.batteryLevel?.takeIf { it > -1F } ?: return@hook
param.setArg(0, param.arg<String>(0) + " (${(batteryLevel * 100).toInt()}%)")
}
findClass("com.snap.map_friend_focus_view.MapFocusViewFriendSectionDataModel").hookConstructor(HookStage.AFTER) { param ->
val instance = param.thisObject<Any>()
val userId = instance.getObjectField("_userId") as? String ?: return@hookConstructor
val batteryLevel = locationHistory[userId]?.batteryLevel?.takeIf { it > -1F } ?: return@hookConstructor
param.thisObject<Any>().dataBuilder {
val prevText = get<String?>("_lastSeen")?.let { " - $it" } ?: ""
set("_lastSeen", "(${(batteryLevel * 100).toInt()}%)$prevText")
}
}
}
context.event.subscribe(AddViewEvent::class) { event ->
if (!event.viewClassName.endsWith("MapScreenRoot")) return@subscribe
event.view.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {
val mapView = event.view.findViewById<View>(mapViewId) ?: throw IllegalStateException("Map view not found")
val view = (mapView.parent as ViewGroup).children().firstOrNull { it is RelativeLayout } as? RelativeLayout ?: throw IllegalStateException("Map view parent not found")
view.addView(createComposeView(view.context) {
val darkTheme = remember { context.androidContext.isDarkTheme() }
Box(
modifier = Modifier.padding(start = 8.dp)
) {
FilledIconButton(
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = if (darkTheme) Color(0xFF1D1D1D) else Color.White,
contentColor = if (darkTheme) Color.White else Color(0xFF151A1A),
),
onClick = { openManagementOverlay() }
) {
Icon(Icons.Default.EditLocation, contentDescription = null)
}
}
}.apply {
layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
addRule(RelativeLayout.ALIGN_PARENT_LEFT)
setMargins(0, (60 * context.resources.displayMetrics.density).toInt(), 0, 0)
}
})
// Overlay Compose UI is now created in the app module only!
// No Compose code or theming here in the core module.
}
override fun onViewDetachedFromWindow(v: View) {}
})
}
context.event.subscribe(UnaryCallEvent::class) { event ->
if (event.uri == "/snapchat.valis.Valis/SendClientUpdate") {
event.buffer = ProtoEditor(event.buffer).apply {
@@ -279,7 +213,6 @@ class BetterLocation : Feature("Better Location") {
}.toByteArray()
}
}
context.mappings.useMapper(CallbackMapper::class) {
callbacks.getClass("ServerStreamingEventHandler")?.hook("onEvent", HookStage.BEFORE) { param ->
val buffer = param.argNullable<ByteBuffer>(1)?.let {
@@ -289,13 +222,11 @@ class BetterLocation : Feature("Better Location") {
onLocationEvent(ProtoReader(buffer))
}
}
findClass("com.snapchat.client.grpc.ClientStreamSendHandler\$CppProxy").hook("send", HookStage.BEFORE) { param ->
val array = param.arg<ByteBuffer>(0).let {
it.position(0)
ByteArray(it.capacity()).also { buffer -> it.get(buffer); it.position(0) }
}
param.setArg(0, ProtoEditor(array).apply {
edit {
editClientUpdate(this)
@@ -305,4 +236,4 @@ class BetterLocation : Feature("Better Location") {
})
}
}
}
}

View File

@@ -23,16 +23,19 @@ import java.lang.reflect.Method
import java.nio.ByteBuffer
class FriendTracker : Feature("Friend Tracker") {
private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>() // conversationId -> (userId -> state)
private val conversationPresenceState = mutableMapOf<String, MutableMap<String, FriendPresenceState?>>()
private val tracker by lazyBridge { context.bridgeClient.getTracker() }
private val notificationManager by lazy { context.androidContext.getSystemService(NotificationManager::class.java).apply {
createNotificationChannel(NotificationChannel(
"friend_tracker",
"Friend Tracker",
NotificationManager.IMPORTANCE_DEFAULT
))
} }
private val notificationManager by lazy {
context.androidContext.getSystemService(NotificationManager::class.java).apply {
createNotificationChannel(
NotificationChannel(
"friend_tracker",
"Friend Tracker",
NotificationManager.IMPORTANCE_DEFAULT
)
)
}
}
private fun getTrackedEvents(eventType: TrackerEventType): TrackerEventsResult? {
return runCatching {
tracker.getTrackedEvents(eventType.key)?.let {
@@ -42,16 +45,15 @@ class FriendTracker : Feature("Friend Tracker") {
context.log.error("Failed to get tracked events for $eventType", it)
}.getOrNull()
}
private fun isInConversation(conversationId: String?) = context.feature(Messaging::class).openedConversationUUID?.toString() == conversationId
private fun isInConversation(conversationId: String?) =
context.feature(Messaging::class).openedConversationUUID?.toString() == conversationId
private fun sendInfoNotification(id: Int = System.nanoTime().toInt(), text: String) {
notificationManager.notify(
id,
Notification.Builder(
context.androidContext,
"friend_tracker"
)
)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setAutoCancel(true)
.setShowWhen(true)
@@ -68,11 +70,9 @@ class FriendTracker : Feature("Friend Tracker") {
.build()
)
}
private fun handleVolatileEvent(protoReader: ProtoReader) {
context.log.verbose("volatile event\n$protoReader")
}
private fun dispatchEvents(
eventType: TrackerEventType,
conversationId: String,
@@ -82,17 +82,13 @@ class FriendTracker : Feature("Friend Tracker") {
val feedEntry = context.database.getFeedEntryByConversationId(conversationId)
val conversationName = feedEntry?.feedDisplayName ?: "DMs"
val authorName = context.database.getFriendInfo(userId)?.mutableUsername ?: "Unknown"
context.log.verbose("$authorName $eventType in $conversationName")
getTrackedEvents(eventType)?.takeIf { it.canTrackOn(conversationId, userId) }?.getActions()?.forEach { (action, params) ->
if ((params.onlyWhenAppActive || action == TrackerRuleAction.IN_APP_NOTIFICATION) && context.isMainActivityPaused) return@forEach
if (params.onlyWhenAppInactive && !context.isMainActivityPaused) return@forEach
if (params.onlyInsideConversation && !isInConversation(conversationId)) return@forEach
if (params.onlyOutsideConversation && isInConversation(conversationId)) return@forEach
context.log.verbose("dispatching $action for $eventType in $conversationName")
when (action) {
TrackerRuleAction.PUSH_NOTIFICATION -> {
if (params.noPushNotificationWhenAppActive && !context.isMainActivityPaused) return@forEach
@@ -111,14 +107,15 @@ class FriendTracker : Feature("Friend Tracker") {
eventType.key,
extras
)
else -> {}
TrackerRuleAction.CUSTOM -> {
// If TrackerRuleAction enum has a CUSTOM branch, handle it here!
// If not used, you may log or leave empty as needed.
}
}
}
}
private fun onConversationPresenceUpdate(conversationId: String, userId: String, oldState: FriendPresenceState?, currentState: FriendPresenceState?) {
context.log.verbose("presence state for $userId in conversation $conversationId\n$currentState")
val eventType = when {
(oldState == null || currentState?.bitmojiPresent == false) && currentState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_ENTER
(currentState == null || oldState?.bitmojiPresent == false) && oldState?.bitmojiPresent == true -> TrackerEventType.CONVERSATION_EXIT
@@ -128,14 +125,11 @@ class FriendTracker : Feature("Friend Tracker") {
oldState?.peeking == true && (currentState == null || !currentState.peeking) -> TrackerEventType.STOPPED_PEEKING
else -> null
} ?: return
dispatchEvents(eventType, conversationId, userId)
}
private fun onConversationMessagingEvent(event: SessionEvent) {
context.log.verbose("conversation messaging event\n${event.type} in ${event.conversationId} from ${event.authorUserId}")
val eventType = when(event.type) {
val eventType = when (event.type) {
SessionEventType.MESSAGE_READ_RECEIPTS -> TrackerEventType.MESSAGE_READ
SessionEventType.MESSAGE_DELETED -> TrackerEventType.MESSAGE_DELETED
SessionEventType.MESSAGE_REACTION_ADD -> TrackerEventType.MESSAGE_REACTION_ADD
@@ -148,36 +142,30 @@ class FriendTracker : Feature("Friend Tracker") {
SessionEventType.SNAP_REPLAYED_TWICE -> TrackerEventType.SNAP_REPLAYED_TWICE
SessionEventType.SNAP_SCREENSHOT -> TrackerEventType.SNAP_SCREENSHOT
SessionEventType.SNAP_SCREEN_RECORD -> TrackerEventType.SNAP_SCREEN_RECORD
else -> return
}
val conversationMessage by lazy {
(event as? SessionMessageEvent)?.serverMessageId?.let { context.database.getConversationServerMessage(event.conversationId, it) }
}
dispatchEvents(eventType, event.conversationId, event.authorUserId, extras = conversationMessage?.takeIf {
eventType == TrackerEventType.MESSAGE_READ ||
eventType == TrackerEventType.MESSAGE_REACTION_ADD ||
eventType == TrackerEventType.MESSAGE_REACTION_REMOVE ||
eventType == TrackerEventType.MESSAGE_DELETED ||
eventType == TrackerEventType.MESSAGE_SAVED ||
eventType == TrackerEventType.MESSAGE_UNSAVED ||
eventType == TrackerEventType.MESSAGE_EDITED
eventType == TrackerEventType.MESSAGE_REACTION_ADD ||
eventType == TrackerEventType.MESSAGE_REACTION_REMOVE ||
eventType == TrackerEventType.MESSAGE_DELETED ||
eventType == TrackerEventType.MESSAGE_SAVED ||
eventType == TrackerEventType.MESSAGE_UNSAVED ||
eventType == TrackerEventType.MESSAGE_EDITED
}?.contentType?.let { ContentType.fromId(it).name } ?: "")
}
private fun handlePresenceEvent(protoReader: ProtoReader) {
val conversationId = protoReader.getString(6) ?: return
val presenceMap = conversationPresenceState.getOrPut(conversationId) { mutableMapOf() }.toMutableMap()
val userIds = mutableSetOf<String>()
protoReader.eachBuffer(4) {
val participantUserId = getString(1)?.takeIf { it.contains(":") }?.substringBefore(":") ?: return@eachBuffer
userIds.add(participantUserId)
if (participantUserId == context.database.myUserId) return@eachBuffer
val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' } ?: return@eachBuffer
val stateMap = getVarInt(2, 1)?.toString(2)?.padStart(16, '0')?.reversed()?.map { it == '1' }
?: return@eachBuffer
presenceMap[participantUserId] = FriendPresenceState(
bitmojiPresent = stateMap[0],
typing = stateMap[4],
@@ -186,28 +174,21 @@ class FriendTracker : Feature("Friend Tracker") {
peeking = stateMap[8]
)
}
presenceMap.keys.filterNot { it in userIds }.forEach { presenceMap[it] = null }
presenceMap.forEach { (userId, state) ->
val oldState = conversationPresenceState[conversationId]?.get(userId)
if (oldState != state) {
onConversationPresenceUpdate(conversationId, userId, oldState, state)
}
}
conversationPresenceState[conversationId] = presenceMap
}
private fun handleMessagingEvent(protoReader: ProtoReader) {
// read receipts
protoReader.followPath(12) {
val conversationId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
followPath(7) readReceipts@{
val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@readReceipts
val serverMessageId = getVarInt(2, 2) ?: return@readReceipts
onConversationMessagingEvent(
SessionMessageEvent(
SessionEventType.MESSAGE_READ_RECEIPTS,
@@ -218,12 +199,10 @@ class FriendTracker : Feature("Friend Tracker") {
)
}
}
protoReader.followPath(13, 1, 4) {
val serverMessageId = getVarInt(1) ?: return@followPath
val senderId = getByteArray(2, 1) ?: return@followPath
val conversationId = getByteArray(3, 1, 1, 1) ?: return@followPath
onConversationMessagingEvent(
SessionMessageEvent(
SessionEventType.MESSAGE_EDITED,
@@ -233,12 +212,10 @@ class FriendTracker : Feature("Friend Tracker") {
)
)
}
protoReader.followPath(6, 2) {
val conversationId = getByteArray(3, 1)?.toSnapUUID()?.toString() ?: return@followPath
val senderId = getByteArray(1, 1)?.toSnapUUID()?.toString() ?: return@followPath
val serverMessageId = getVarInt(2) ?: return@followPath
if (contains(4)) {
onConversationMessagingEvent(
SessionMessageEvent(
@@ -249,7 +226,6 @@ class FriendTracker : Feature("Friend Tracker") {
)
)
}
if (contains(13)) {
onConversationMessagingEvent(
SessionMessageEvent(
@@ -260,7 +236,6 @@ class FriendTracker : Feature("Friend Tracker") {
)
)
}
if (contains(6) || contains(7)) {
onConversationMessagingEvent(
SessionMessageEvent(
@@ -271,7 +246,6 @@ class FriendTracker : Feature("Friend Tracker") {
)
)
}
if (contains(11) || contains(12)) {
onConversationMessagingEvent(
SessionMessageEvent(
@@ -282,7 +256,6 @@ class FriendTracker : Feature("Friend Tracker") {
)
)
}
followPath(16) {
onConversationMessagingEvent(
SessionMessageEvent(
@@ -290,13 +263,11 @@ class FriendTracker : Feature("Friend Tracker") {
)
)
}
if (contains(17)) {
onConversationMessagingEvent(
SessionMessageEvent(SessionEventType.MESSAGE_REACTION_REMOVE, conversationId, senderId, serverMessageId)
)
}
followPath(8) {
onConversationMessagingEvent(
SessionMessageEvent(SessionEventType.MESSAGE_DELETED, conversationId, senderId, serverMessageId, messageData = getByteArray(1))
@@ -304,18 +275,14 @@ class FriendTracker : Feature("Friend Tracker") {
}
}
}
override fun init() {
val sessionEventsConfig = context.config.friendTracker
if (sessionEventsConfig.globalState != true) return
if (sessionEventsConfig.allowRunningInBackground.get()) {
findClass("com.snapchat.client.duplex.DuplexClient\$CppProxy").apply {
// prevent disabling events when the app is inactive
hook("appStateChanged", HookStage.BEFORE) { param ->
if (param.arg<Any>(0).toString() == "INACTIVE") param.setResult(null)
}
// allow events when a notification is received
hookConstructor(HookStage.AFTER) { param ->
methods.first { it.name == "appStateChanged" }.let { method ->
method.invoke(param.thisObject(), method.parameterTypes[0].enumConstants!!.first { it.toString() == "ACTIVE" })
@@ -323,12 +290,10 @@ class FriendTracker : Feature("Friend Tracker") {
}
}
}
if (sessionEventsConfig.recordMessagingEvents.get()) {
val messageHandlerClass = findClass("com.snapchat.client.duplex.MessageHandler\$CppProxy").apply {
hook("onReceive", HookStage.BEFORE) { param ->
param.setResult(null)
val byteBuffer = param.arg<ByteBuffer>(0)
val content = byteBuffer.let {
val bytes = ByteArray(it.limit())
@@ -342,7 +307,6 @@ class FriendTracker : Feature("Friend Tracker") {
handleVolatileEvent(eventData)
return@hook
}
if (it == "presence") {
handlePresenceEvent(eventData)
return@hook
@@ -352,14 +316,11 @@ class FriendTracker : Feature("Friend Tracker") {
}
hook("nativeDestroy", HookStage.BEFORE) { it.setResult(null) }
}
findClass("com.snapchat.client.messaging.Session").hook("create", HookStage.BEFORE) { param ->
if (!NativeLib.initialized) {
context.log.warn("Can't register duplex message handler, native lib not initialized")
return@hook
}
val method = param.method() as Method
val duplexClient = method.parameterTypes.indexOfFirst { it.name.endsWith("DuplexClient") }.let {
param.arg<Any>(it)
@@ -380,4 +341,4 @@ class FriendTracker : Feature("Friend Tracker") {
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
package me.rhunk.snapenhance.core.features.impl.ui
import android.content.res.TypedArray
import android.util.TypedValue
import me.rhunk.snapenhance.core.features.Feature
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
import me.rhunk.snapenhance.core.util.ktx.getObjectField
class CustomTheming : Feature("Custom Theming") {
private val amoledBlack = 0xFF000000.toInt()
private val onlyPatchId = 0x7f0404b8 // The attrId you want to patch for AMOLED
private val colorTypes = setOf(
TypedValue.TYPE_INT_COLOR_ARGB8,
TypedValue.TYPE_INT_COLOR_RGB8,
TypedValue.TYPE_INT_COLOR_ARGB4,
TypedValue.TYPE_INT_COLOR_RGB4
)
override fun init() {
if (!context.config.userInterface.forceAmoledTheme.get()) return
onNextActivityCreate {
context.androidContext.theme.javaClass
.getMethod("obtainStyledAttributes", IntArray::class.java)
.hook(HookStage.AFTER) { param ->
val array = param.arg<IntArray>(0)
val attrId = array[0]
val result = param.getResult() as TypedArray
val typedArrayData = result.getObjectField("mData") as IntArray
val type = result.getType(0)
if (type in colorTypes && attrId == onlyPatchId) {
typedArrayData[1] = amoledBlack
context.log.error(
"[AMOLED PATCH] Patched ONLY attrId 0x${attrId.toString(16)} to AMOLED black"
)
}
}
}
}
}

View File

@@ -10,11 +10,11 @@ import me.rhunk.snapenhance.core.bridge.BridgeClient
import me.rhunk.snapenhance.core.util.hook.HookStage
import me.rhunk.snapenhance.core.util.hook.hook
@SuppressLint("PrivateApi")
class CoreLogger(
private val bridgeClient: BridgeClient
): AbstractLogger(LogChannel.CORE) {
) : AbstractLogger(LogChannel.CORE) {
companion object {
private const val TAG = "SnapEnhanceCore"
@@ -33,19 +33,23 @@ class CoreLogger(
private var invokeOriginalPrintLog: (Int, String, String) -> Unit
init {
val printLnMethod = Log::class.java.getDeclaredMethod("println", Int::class.java, String::class.java, String::class.java)
val printLnMethod = Log::class.java.getDeclaredMethod(
"println",
Int::class.java,
String::class.java,
String::class.java
)
printLnMethod.hook(HookStage.BEFORE) { param ->
val priority = param.arg(0) as Int
val tag = param.arg(1) as String
val message = param.arg(2) as String
internalLog(tag, LogLevel.fromPriority(priority) ?: LogLevel.INFO, message)
}
invokeOriginalPrintLog = { priority, tag, message ->
XposedBridge.invokeOriginalMethod(
printLnMethod,
null,
arrayOf(priority, tag, message)
arrayOf<Any?>(priority, tag, message)
)
}
}
@@ -59,19 +63,13 @@ class CoreLogger(
}
override fun debug(message: Any?, tag: String) = internalLog(tag, LogLevel.DEBUG, message)
override fun error(message: Any?, tag: String) = internalLog(tag, LogLevel.ERROR, message)
override fun error(message: Any?, throwable: Throwable, tag: String) {
internalLog(tag, LogLevel.ERROR, message)
internalLog(tag, LogLevel.ERROR, throwable.stackTraceToString())
}
override fun info(message: Any?, tag: String) = internalLog(tag, LogLevel.INFO, message)
override fun verbose(message: Any?, tag: String) = internalLog(tag, LogLevel.VERBOSE, message)
override fun warn(message: Any?, tag: String) = internalLog(tag, LogLevel.WARN, message)
override fun assert(message: Any?, tag: String) = internalLog(tag, LogLevel.ASSERT, message)
}
}

View File

@@ -75,9 +75,9 @@ val AppleLogo by lazy {
}
val Snapenhance by lazy {
val PurrfectSnap by lazy {
ImageVector.Builder(
name = "SnapEnhance",
name = "PurrfectSnap",
defaultWidth = 247.92.dp,
defaultHeight = 39.84.dp,
viewportWidth = 247.92f,

View File

@@ -9,6 +9,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
@@ -49,9 +50,10 @@ typealias CustomComposable = @Composable BoxScope.() -> Unit
class InAppOverlay(
private val context: ModContext
) {
enum class ToastPosition { Start, Center, End }
companion object {
fun showCrashOverlay(content: String, throwable: Throwable? = null) {
// deny network requests
SnapEnhance.classCache.apply {
unifiedGrpcService.hook("unaryCall", HookStage.BEFORE) { param ->
param.setResult(null)
@@ -60,7 +62,6 @@ class InAppOverlay(
param.setResult(null)
}
}
Hooker.ephemeralHook(Activity::class.java, "onPostCreate", HookStage.AFTER) { param ->
val contentView = param.thisObject<Activity>().findViewById<FrameLayout>(android.R.id.content)
contentView.children().forEach { it.visibility = View.GONE }
@@ -80,7 +81,7 @@ class InAppOverlay(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "SnapEnhance",
text = "PurrfectSnap",
fontSize = 28.sp
)
Spacer(modifier = Modifier.height(40.dp))
@@ -93,9 +94,9 @@ class InAppOverlay(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
throwable?.let {
throwable?.let { th ->
Button(onClick = {
contentView.context.copyToClipboard(it.stackTraceToString())
contentView.context.copyToClipboard(th.stackTraceToString())
}) {
Text("Copy error to clipboard")
}
@@ -117,7 +118,6 @@ class InAppOverlay(
}
}
}
inner class Toast(
val composable: @Composable Toast.() -> Unit,
val durationMs: Int
@@ -125,10 +125,8 @@ class InAppOverlay(
var shown by mutableStateOf(false)
var visible by mutableStateOf(false)
}
private val toasts = mutableStateListOf<Toast>()
private val customComposables = mutableStateListOf<CustomComposable>()
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun OverlayContent() {
@@ -144,7 +142,6 @@ class InAppOverlay(
animationSpec = if (toast.visible) tween(durationMillis = 150) else tween(durationMillis = 300),
label = "toast"
)
LaunchedEffect(toast) {
toast.visible = true
if (toast.durationMs < 0) return@LaunchedEffect
@@ -156,34 +153,36 @@ class InAppOverlay(
if (toasts.isNotEmpty() && toasts.all { it.shown }) toasts.clear()
}
}
val deviceWidth = LocalContext.current.resources.displayMetrics.widthPixels
val delayAnimationSpec = rememberSplineBasedDecay<Float>()
val anchors = DraggableAnchors<ToastPosition> {
ToastPosition.Start at -deviceWidth.toFloat()
ToastPosition.Center at 0f
ToastPosition.End at deviceWidth.toFloat()
}
val draggableState = remember {
AnchoredDraggableState(
initialValue = 0,
anchors = DraggableAnchors {
-1 at -deviceWidth.toFloat()
0 at 0f
1 at deviceWidth.toFloat()
},
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { deviceWidth / 2f },
snapAnimationSpec = tween(),
decayAnimationSpec = delayAnimationSpec,
confirmValueChange = {
if (it == 0) return@AnchoredDraggableState true
toast.visible = false
true
}
initialValue = ToastPosition.Center,
anchors = anchors,
)
}
LaunchedEffect(draggableState.currentValue) {
if (draggableState.currentValue != ToastPosition.Center) {
toast.visible = false
}
}
val flingBehavior = AnchoredDraggableDefaults.flingBehavior(draggableState, animationSpec = tween())
Box(
modifier = Modifier
.fillMaxWidth()
.anchoredDraggable(draggableState, Orientation.Horizontal)
.offset { IntOffset(draggableState.offset.roundToInt(), 0) }
.anchoredDraggable(
state = draggableState,
orientation = Orientation.Horizontal,
flingBehavior = flingBehavior
)
.offset {
val offsetValue = draggableState.offset
IntOffset(offsetValue.roundToInt(), 0)
}
.graphicsLayer {
alpha = animation
translationY = -100.dp.toPx() * (1 - animation)
@@ -194,15 +193,12 @@ class InAppOverlay(
}
}
}
customComposables.forEach {
it()
customComposables.forEach { customComposable ->
customComposable()
}
}
}
private val overlayTag = Random.nextLong()
private fun injectOverlay(activity: Activity) {
val root = activity.findViewById<FrameLayout>(android.R.id.content)
activity.runOnUiThread {
@@ -217,39 +213,32 @@ class InAppOverlay(
})
}
}
fun onActivityCreate(activity: Activity) {
injectOverlay(activity)
}
fun addCustomComposable(composable: CustomComposable) {
customComposables.add(composable)
}
fun removeCustomComposable(composable: CustomComposable) {
customComposables.remove(composable)
}
@Composable
private fun DurationProgress(
duration: Int,
modifier: Modifier = Modifier
) {
val progress = remember { Animatable(1f) }
LaunchedEffect(Unit) {
progress.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
)
}
LinearProgressIndicator(
progress = { progress.value },
modifier = modifier
)
}
fun showStatusToast(
icon: ImageVector,
text: String,
@@ -266,7 +255,6 @@ class InAppOverlay(
showDuration = showDuration
)
}
private fun showToast(
icon: @Composable () -> Unit = {
Icon(Icons.Outlined.Warning, contentDescription = "icon", modifier = Modifier.size(32.dp))
@@ -303,4 +291,4 @@ class InAppOverlay(
durationMs = durationMs
))
}
}
}

View File

@@ -22,6 +22,7 @@ class MenuViewInjector : Feature("MenuViewInjector") {
OperaViewerIcons(),
FriendFeedInfoMenu(),
ChatActionMenu(),
SettingsGearInjector(),
).associateBy {
it.context = context
it.menuViewInjector = this

View File

@@ -0,0 +1,91 @@
package me.rhunk.snapenhance.core.ui.menu.impl
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import me.rhunk.snapenhance.common.ui.OverlayType
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.ui.menu.AbstractMenu
import me.rhunk.snapenhance.core.util.ktx.getDrawable
import me.rhunk.snapenhance.core.util.ktx.getStyledAttributes
class SettingsGearInjector : AbstractMenu() {
private val hovaHeaderAddFriendIconId by lazy {
this@SettingsGearInjector.context.resources.getIdentifier("hova_header_add_friend_icon", "id", "com.snapchat.android")
}
private val gearIconId = View.generateViewId()
private val logTag = "SettingsGearInjector"
override fun init() {
this@SettingsGearInjector.context.log.info("Initializing", logTag)
if (this@SettingsGearInjector.context.config.userInterface.settingsMenu.get() != "legacy") {
this@SettingsGearInjector.context.log.info("Settings menu is not legacy, aborting init.", logTag)
return
}
this@SettingsGearInjector.context.event.subscribe(AddViewEvent::class) { event ->
if (event.view.id == hovaHeaderAddFriendIconId) {
this@SettingsGearInjector.context.log.info("AddViewEvent triggered for hova_header_add_friend_icon", logTag)
val parent = event.parent as? FrameLayout ?: return@subscribe
if (parent.findViewById<View>(gearIconId) != null) {
this@SettingsGearInjector.context.log.info("Gear icon already exists, skipping.", logTag)
return@subscribe
}
this@SettingsGearInjector.context.log.info("Creating and adding gear icon.", logTag)
val gearIcon = ImageView(parent.context).apply {
id = gearIconId
val resources = this@SettingsGearInjector.context.resources
val theme = this@SettingsGearInjector.context.androidContext.theme
setImageDrawable(resources.getDrawable("svg_settings_32x32", theme))
resources.getStyledAttributes("headerButtonOpaqueIconTint", theme).getColorStateList(0)?.let {
imageTintList = it
}
setOnClickListener {
this@SettingsGearInjector.context.log.info("Gear icon clicked.", logTag)
this@SettingsGearInjector.context.bridgeClient.openOverlay(OverlayType.SETTINGS)
}
}
val layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = android.view.Gravity.END or android.view.Gravity.CENTER_VERTICAL
}
parent.addView(gearIcon, layoutParams)
this@SettingsGearInjector.context.log.info("Gear icon added to parent. Posting position and size update.", logTag)
event.view.post {
try {
this@SettingsGearInjector.context.log.info("Running position and size update.", logTag)
val addFriendIcon = event.view
val friendIconParams = addFriendIcon.layoutParams as ViewGroup.MarginLayoutParams
val friendIconMarginEnd = friendIconParams.marginEnd
val friendIconWidth = addFriendIcon.width
val friendIconHeight = addFriendIcon.height
this@SettingsGearInjector.context.log.info("Friend icon details: width=$friendIconWidth, height=$friendIconHeight, marginEnd=$friendIconMarginEnd", logTag)
val newMargin = friendIconWidth + friendIconMarginEnd + this@SettingsGearInjector.context.userInterface.dpToPx(4)
this@SettingsGearInjector.context.log.info("Calculated new marginEnd for gear icon: $newMargin", logTag)
(gearIcon.layoutParams as FrameLayout.LayoutParams).apply {
height = friendIconHeight
width = friendIconHeight // Make it a square
marginEnd = newMargin
}.also {
gearIcon.layoutParams = it
}
this@SettingsGearInjector.context.log.info("Successfully updated gear icon position and size.", logTag)
} catch (t: Throwable) {
this@SettingsGearInjector.context.log.error("Failed to position or size gear icon", t, logTag)
}
}
}
}
}
}

View File

@@ -14,6 +14,7 @@ class SettingsMenu : AbstractMenu() {
}
override fun init() {
if (context.config.userInterface.settingsMenu.get() != "default") return
context.androidContext.classLoader.loadClass("com.snap.ui.view.SnapFontTextView").hook("setText", HookStage.BEFORE) { param ->
val view = param.thisObject<View>()
if ((view.parent as? FrameLayout)?.findViewById<View>(hovaHeaderSearchIconId) != null) {

View File

@@ -19,41 +19,40 @@ class CallbackBuilder(
private val methodOverrides = mutableListOf<Override>()
fun override(methodName: String, shouldUnhook: Boolean = true, callback: (HookAdapter) -> Unit = {}): CallbackBuilder {
fun override(
methodName: String,
shouldUnhook: Boolean = true,
callback: (HookAdapter) -> Unit = {}
): CallbackBuilder {
methodOverrides.add(Override(methodName, shouldUnhook, callback))
return this
}
fun build(): Any {
//get the first param of the first constructor to get the class of the invoker
val rxEmitter: Class<*> = callbackClass.constructors[0].parameterTypes[0]
//get the emitter field based on the class
val rxEmitterField = callbackClass.fields.first { field: Field ->
// get the first param of the first constructor to get the class of the invoker
val rxEmitter: Class<*> = callbackClass.constructors.first().parameterTypes.first()
// get the emitter field based on the class
val rxEmitterField = callbackClass.fields.firstOrNull { field: Field ->
field.type.isAssignableFrom(rxEmitter)
}
//get the callback field based on the callback class
val callbackInstance = createEmptyObject(callbackClass.constructors[0])!!
} ?: error("No field found assignable from rxEmitter type!")
// get the callback field based on the callback class
val callbackInstance = createEmptyObject(callbackClass.constructors.first())!!
val callbackInstanceHashCode: Int = callbackInstance.hashCode()
val callbackInstanceClass = callbackInstance.javaClass
val unhooks = mutableListOf<XC_MethodHook.Unhook>()
callbackInstanceClass.methods.forEach { method ->
if (method.declaringClass != callbackInstanceClass) return@forEach
if (Modifier.isPrivate(method.modifiers)) return@forEach
//default hook that unhooks the callback and returns null
// default hook that unhooks the callback and returns null
val defaultHook: (HookAdapter) -> Boolean = defaultHook@{
//ensure that's the callback was created by the CallbackBuilder
// ensure that's the callback was created by the CallbackBuilder
if (rxEmitterField.get(it.thisObject()) != null) return@defaultHook false
if ((it.thisObject() as Any).hashCode() != callbackInstanceHashCode) return@defaultHook false
it.setResult(null)
true
}
var hook: (HookAdapter) -> Unit = { defaultHook(it) }
//override the default hook if the method is in the override list
// override the default hook if the method is in the override list
methodOverrides.find { it.methodName == method.name }?.run {
hook = {
if (defaultHook(it)) {
@@ -62,7 +61,6 @@ class CallbackBuilder(
}
}
}
unhooks.add(Hooker.hook(method, HookStage.BEFORE, hook))
}
return callbackInstance
@@ -70,10 +68,10 @@ class CallbackBuilder(
companion object {
fun createEmptyObject(constructor: Constructor<*>): Any? {
//compute the args for the constructor with null or default primitive values
val args = constructor.parameterTypes.map { type: Class<*> ->
// compute the args for the constructor with null or default primitive values
val args: Array<Any?> = constructor.parameterTypes.map { type: Class<*> ->
if (type.isPrimitive) {
return@map when (type.name) {
when (type.name) {
"boolean" -> false
"byte" -> 0.toByte()
"char" -> 0.toChar()
@@ -84,11 +82,9 @@ class CallbackBuilder(
"double" -> 0.0
else -> null
}
}
null
} else null
}.toTypedArray()
return constructor.newInstance(*args)
}
}
}
}

View File

@@ -45,7 +45,7 @@ object LSPatchUpdater {
val seAppApk = File(context.bridgeClient.getApplicationApkPath()).also {
if (!it.canRead()) {
throw IllegalStateException("Cannot read SnapEnhance apk")
throw IllegalStateException("Cannot read PurrfectSnap apk")
}
}
@@ -59,19 +59,19 @@ object LSPatchUpdater {
}
context.log.verbose("updating", TAG)
context.shortToast("Updating SnapEnhance. Please wait...")
context.shortToast("Updating PurrfectSnap. Please wait...")
// copy embedded module to cache
runCatching {
seAppApk.copyTo(embeddedModule, overwrite = true)
}.onFailure {
seAppApk.delete()
context.log.error("Failed to copy embedded module", it, TAG)
context.longToast("Failed to update SnapEnhance. Please check logcat for more details.")
context.longToast("Failed to update PurrfectSnap. Please check logcat for more details.")
context.forceCloseApp()
return
}
context.longToast("SnapEnhance updated!")
context.longToast("PurrfectSnap updated!")
context.log.verbose("updated", TAG)
context.softRestartApp()
}

View File

@@ -60,7 +60,7 @@ object PreviewUtils {
val dx = (outWidth - (scale * sourceWidth)) / 2F
val dy = (outHeight - (scale * sourceHeight)) / 2F
val dest = Bitmap.createBitmap(outWidth, outHeight, source.getConfig())
val dest = Bitmap.createBitmap(outWidth, outHeight, source.getConfig() ?: Bitmap.Config.ARGB_8888)
val canvas = Canvas(dest)
canvas.drawBitmap(source, Matrix().apply {
postScale(scale, scale)
@@ -73,7 +73,7 @@ object PreviewUtils {
val biggestBitmap = if (originalMedia.width * originalMedia.height > overlayLayer.width * overlayLayer.height) originalMedia else overlayLayer
val smallestBitmap = if (biggestBitmap == originalMedia) overlayLayer else originalMedia
val mergedBitmap = Bitmap.createBitmap(biggestBitmap.width, biggestBitmap.height, biggestBitmap.config)
val mergedBitmap = Bitmap.createBitmap(biggestBitmap.width, biggestBitmap.height, biggestBitmap.config ?: Bitmap.Config.ARGB_8888)
with(Canvas(mergedBitmap)) {
val scaleMatrix = Matrix().apply {