feat: Initial commit!
This commit is contained in:
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,6 +144,7 @@ class FeatureManager(
|
||||
SnapScoreChanges(),
|
||||
DisableSnapModeRestrictions(),
|
||||
PreventForcedKeyboard(),
|
||||
CustomTheming(),
|
||||
)
|
||||
|
||||
features.values.toList().forEach { feature ->
|
||||
|
||||
@@ -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") {
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class MenuViewInjector : Feature("MenuViewInjector") {
|
||||
OperaViewerIcons(),
|
||||
FriendFeedInfoMenu(),
|
||||
ChatActionMenu(),
|
||||
SettingsGearInjector(),
|
||||
).associateBy {
|
||||
it.context = context
|
||||
it.menuViewInjector = this
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user