feat: end-to-end encryption

- message_logger: fix view binder bugs and deleted message color
- fix invalid message rule type
This commit is contained in:
rhunk
2023-09-28 02:12:03 +02:00
parent 061f5cc5a8
commit aaf8f3e43a
22 changed files with 869 additions and 220 deletions

View File

@ -103,6 +103,7 @@ dependencies {
implementation(libs.ffmpeg.kit) implementation(libs.ffmpeg.kit)
implementation(libs.osmdroid.android) implementation(libs.osmdroid.android)
implementation(libs.rhino) implementation(libs.rhino)
implementation(libs.bcprov.jdk18on)
debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") debugImplementation("androidx.compose.ui:ui-tooling:1.4.3")
implementation("androidx.compose.ui:ui-tooling-preview:1.4.3") implementation("androidx.compose.ui:ui-tooling-preview:1.4.3")

View File

@ -22,6 +22,7 @@ import me.rhunk.snapenhance.core.bridge.wrapper.LocaleWrapper
import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper import me.rhunk.snapenhance.core.bridge.wrapper.MappingsWrapper
import me.rhunk.snapenhance.core.config.ModConfig import me.rhunk.snapenhance.core.config.ModConfig
import me.rhunk.snapenhance.download.DownloadTaskManager import me.rhunk.snapenhance.download.DownloadTaskManager
import me.rhunk.snapenhance.e2ee.E2EEImplementation
import me.rhunk.snapenhance.messaging.ModDatabase import me.rhunk.snapenhance.messaging.ModDatabase
import me.rhunk.snapenhance.messaging.StreaksReminder import me.rhunk.snapenhance.messaging.StreaksReminder
import me.rhunk.snapenhance.scripting.RemoteScriptManager import me.rhunk.snapenhance.scripting.RemoteScriptManager
@ -58,6 +59,7 @@ class RemoteSideContext(
val log = LogManager(this) val log = LogManager(this)
val scriptManager = RemoteScriptManager(this) val scriptManager = RemoteScriptManager(this)
val settingsOverlay = SettingsOverlay(this) val settingsOverlay = SettingsOverlay(this)
val e2eeImplementation = E2EEImplementation(this)
//used to load bitmoji selfies and download previews //used to load bitmoji selfies and download previews
val imageLoader by lazy { val imageLoader by lazy {

View File

@ -187,6 +187,9 @@ class BridgeService : Service() {
} }
override fun getScriptingInterface() = remoteSideContext.scriptManager override fun getScriptingInterface() = remoteSideContext.scriptManager
override fun getE2eeInterface() = remoteSideContext.e2eeImplementation
override fun openSettingsOverlay() { override fun openSettingsOverlay() {
runCatching { runCatching {
remoteSideContext.settingsOverlay.show() remoteSideContext.settingsOverlay.show()

View File

@ -0,0 +1,155 @@
package me.rhunk.snapenhance.e2ee
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface
import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult
import org.bouncycastle.pqc.crypto.crystals.kyber.*
import java.io.File
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class E2EEImplementation (
private val context: RemoteSideContext
) : E2eeInterface.Stub() {
private val kyberDefaultParameters = KyberParameters.kyber1024_aes
private val secureRandom = SecureRandom()
private val e2eeFolder by lazy { File(context.androidContext.filesDir, "e2ee").also {
if (!it.exists()) it.mkdirs()
}}
private val pairingFolder by lazy { File(context.androidContext.cacheDir, "e2ee-pairing").also {
if (!it.exists()) it.mkdirs()
} }
fun storeSharedSecretKey(friendId: String, key: ByteArray) {
File(e2eeFolder, "$friendId.key").writeBytes(key)
}
fun getSharedSecretKey(friendId: String): ByteArray? {
return runCatching {
File(e2eeFolder, "$friendId.key").readBytes()
}.onFailure {
context.log.error("Failed to read shared secret key", it)
}.getOrNull()
}
fun deleteSharedSecretKey(friendId: String) {
File(e2eeFolder, "$friendId.key").delete()
}
override fun createKeyExchange(friendId: String): ByteArray? {
val keyPairGenerator = KyberKeyPairGenerator()
keyPairGenerator.init(
KyberKeyGenerationParameters(secureRandom, kyberDefaultParameters)
)
val keyPair = keyPairGenerator.generateKeyPair()
val publicKey = keyPair.public as KyberPublicKeyParameters
val privateKey = keyPair.private as KyberPrivateKeyParameters
runCatching {
File(pairingFolder, "$friendId.private").writeBytes(privateKey.encoded)
File(pairingFolder, "$friendId.public").writeBytes(publicKey.encoded)
}.onFailure {
context.log.error("Failed to write private key to file", it)
return null
}
return publicKey.encoded
}
override fun acceptPairingRequest(friendId: String, publicKey: ByteArray): ByteArray? {
val kemGen = KyberKEMGenerator(secureRandom)
val encapsulatedSecret = runCatching {
kemGen.generateEncapsulated(
KyberPublicKeyParameters(
kyberDefaultParameters,
publicKey
)
)
}.onFailure {
context.log.error("Failed to generate encapsulated secret", it)
return null
}.getOrThrow()
runCatching {
storeSharedSecretKey(friendId, encapsulatedSecret.secret)
}.onFailure {
context.log.error("Failed to store shared secret key", it)
return null
}
return encapsulatedSecret.encapsulation
}
override fun acceptPairingResponse(friendId: String, encapsulatedSecret: ByteArray): Boolean {
val privateKey = runCatching {
val secretKey = File(pairingFolder, "$friendId.private").readBytes()
object: KyberPrivateKeyParameters(kyberDefaultParameters, null, null, null, null, null) {
override fun getEncoded() = secretKey
}
}.onFailure {
context.log.error("Failed to read private key from file", it)
return false
}.getOrThrow()
val kemExtractor = KyberKEMExtractor(privateKey)
val sharedSecret = runCatching {
kemExtractor.extractSecret(encapsulatedSecret)
}.onFailure {
context.log.error("Failed to extract shared secret", it)
return false
}.getOrThrow()
runCatching {
storeSharedSecretKey(friendId, sharedSecret)
}.onFailure {
context.log.error("Failed to store shared secret key", it)
return false
}
return true
}
override fun friendKeyExists(friendId: String): Boolean {
return File(e2eeFolder, "$friendId.key").exists()
}
override fun encryptMessage(friendId: String, message: ByteArray): EncryptionResult? {
val encryptionKey = runCatching {
File(e2eeFolder, "$friendId.key").readBytes()
}.onFailure {
context.log.error("Failed to read shared secret key", it)
}.getOrNull()
return runCatching {
val iv = ByteArray(16).apply { secureRandom.nextBytes(this) }
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv))
EncryptionResult().apply {
this.iv = iv
this.ciphertext = cipher.doFinal(message)
}
}.onFailure {
context.log.error("Failed to encrypt message for $friendId", it)
}.getOrNull()
}
override fun decryptMessage(friendId: String, message: ByteArray, iv: ByteArray): ByteArray? {
val encryptionKey = runCatching {
File(e2eeFolder, "$friendId.key").readBytes()
}.onFailure {
context.log.error("Failed to read shared secret key", it)
return null
}.getOrNull()
return runCatching {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(encryptionKey, "AES"), IvParameterSpec(iv))
cipher.doFinal(message)
}.onFailure {
context.log.error("Failed to decrypt message from $friendId", it)
return null
}.getOrNull()
}
}

View File

@ -158,7 +158,11 @@ class ModDatabase(
)).use { cursor -> )).use { cursor ->
val rules = mutableListOf<MessagingRuleType>() val rules = mutableListOf<MessagingRuleType>()
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!)) runCatching {
rules.add(MessagingRuleType.getByName(cursor.getStringOrNull("type")!!))
}.onFailure {
context.log.error("Failed to parse rule", it)
}
} }
rules rules
} }
@ -197,12 +201,14 @@ class ModDatabase(
executeAsync { executeAsync {
database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId)) database.execSQL("DELETE FROM friends WHERE userId = ?", arrayOf(userId))
database.execSQL("DELETE FROM streaks WHERE userId = ?", arrayOf(userId)) database.execSQL("DELETE FROM streaks WHERE userId = ?", arrayOf(userId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(userId))
} }
} }
fun deleteGroup(conversationId: String) { fun deleteGroup(conversationId: String) {
executeAsync { executeAsync {
database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId)) database.execSQL("DELETE FROM groups WHERE conversationId = ?", arrayOf(conversationId))
database.execSQL("DELETE FROM rules WHERE targetUuid = ?", arrayOf(conversationId))
} }
} }

View File

@ -1,12 +1,7 @@
package me.rhunk.snapenhance.ui.manager.sections.social package me.rhunk.snapenhance.ui.manager.sections.social
import androidx.compose.foundation.layout.Column import android.content.Intent
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -32,6 +27,10 @@ import me.rhunk.snapenhance.core.messaging.MessagingRuleType
import me.rhunk.snapenhance.core.messaging.SocialScope import me.rhunk.snapenhance.core.messaging.SocialScope
import me.rhunk.snapenhance.ui.util.BitmojiImage import me.rhunk.snapenhance.ui.util.BitmojiImage
import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie import me.rhunk.snapenhance.core.util.snap.BitmojiSelfie
import me.rhunk.snapenhance.ui.util.AlertDialogs
import me.rhunk.snapenhance.ui.util.Dialog
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
class ScopeContent( class ScopeContent(
private val context: RemoteSideContext, private val context: RemoteSideContext,
@ -40,6 +39,7 @@ class ScopeContent(
val scope: SocialScope, val scope: SocialScope,
private val id: String private val id: String
) { ) {
private val dialogs by lazy { AlertDialogs(context.translation) }
private val translation by lazy { context.translation.getCategory("manager.sections.social") } private val translation by lazy { context.translation.getCategory("manager.sections.social") }
fun deleteScope(coroutineScope: CoroutineScope) { fun deleteScope(coroutineScope: CoroutineScope) {
@ -162,6 +162,7 @@ class ScopeContent(
return "Expired" return "Expired"
} }
@OptIn(ExperimentalEncodingApi::class)
@Composable @Composable
private fun Friend() { private fun Friend() {
//fetch the friend from the database //fetch the friend from the database
@ -241,6 +242,73 @@ class ScopeContent(
} }
} }
} }
// e2ee section
SectionTitle(translation["e2ee_title"])
var hasSecretKey by remember { mutableStateOf(context.e2eeImplementation.friendKeyExists(friend.userId))}
var importDialog by remember { mutableStateOf(false) }
if (importDialog) {
Dialog(
onDismissRequest = { importDialog = false }
) {
dialogs.RawInputDialog(onDismiss = { importDialog = false }, onConfirm = { newKey ->
importDialog = false
runCatching {
val key = Base64.decode(newKey)
if (key.size != 32) {
context.longToast("Invalid key size (must be 32 bytes)")
return@runCatching
}
context.e2eeImplementation.storeSharedSecretKey(friend.userId, key)
context.longToast("Successfully imported key")
hasSecretKey = true
}.onFailure {
context.longToast("Failed to import key: ${it.message}")
context.log.error("Failed to import key", it)
}
})
}
}
ContentCard {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
if (hasSecretKey) {
OutlinedButton(onClick = {
val secretKey = Base64.encode(context.e2eeImplementation.getSharedSecretKey(friend.userId) ?: return@OutlinedButton)
//TODO: fingerprint auth
context.activity!!.startActivity(Intent.createChooser(Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, secretKey)
type = "text/plain"
}, "").apply {
putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(
Intent().apply {
putExtra(Intent.EXTRA_TEXT, secretKey)
putExtra(Intent.EXTRA_SUBJECT, secretKey)
})
)
})
}) {
Text(
text = "Export Base64",
maxLines = 1
)
}
}
OutlinedButton(onClick = { importDialog = true }) {
Text(
text = "Import Base64",
maxLines = 1
)
}
}
}
} }
} }

View File

@ -238,6 +238,46 @@ class AlertDialogs(
} }
} }
@Composable
fun RawInputDialog(onDismiss: () -> Unit, onConfirm: (value: String) -> Unit) {
val focusRequester = remember { FocusRequester() }
DefaultDialogCard {
val fieldValue = remember {
mutableStateOf(TextFieldValue())
}
TextField(
modifier = Modifier
.fillMaxWidth()
.padding(all = 10.dp)
.onGloballyPositioned {
focusRequester.requestFocus()
}
.focusRequester(focusRequester),
value = fieldValue.value,
onValueChange = {
fieldValue.value = it
},
singleLine = true
)
Row(
modifier = Modifier.padding(top = 10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Button(onClick = { onDismiss() }) {
Text(text = translation["button.cancel"])
}
Button(onClick = {
onConfirm(fieldValue.value.text)
}) {
Text(text = translation["button.ok"])
}
}
}
}
@Composable @Composable
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun MultipleSelectionDialog(property: PropertyPair<*>) { fun MultipleSelectionDialog(property: PropertyPair<*>) {

View File

@ -4,6 +4,7 @@ import java.util.List;
import me.rhunk.snapenhance.bridge.DownloadCallback; import me.rhunk.snapenhance.bridge.DownloadCallback;
import me.rhunk.snapenhance.bridge.SyncCallback; import me.rhunk.snapenhance.bridge.SyncCallback;
import me.rhunk.snapenhance.bridge.scripting.IScripting; import me.rhunk.snapenhance.bridge.scripting.IScripting;
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface;
interface BridgeInterface { interface BridgeInterface {
/** /**
@ -94,6 +95,8 @@ interface BridgeInterface {
IScripting getScriptingInterface(); IScripting getScriptingInterface();
E2eeInterface getE2eeInterface();
void openSettingsOverlay(); void openSettingsOverlay();
void closeSettingsOverlay(); void closeSettingsOverlay();

View File

@ -0,0 +1,34 @@
package me.rhunk.snapenhance.bridge.e2ee;
import me.rhunk.snapenhance.bridge.e2ee.EncryptionResult;
interface E2eeInterface {
/**
* Start a new pairing process with a friend
* @param friendId
* @return the pairing public key
*/
@nullable byte[] createKeyExchange(String friendId);
/**
* Accept a pairing request from a friend
* @param friendId
* @param publicKey the public key received from the friend
* @return the encapsulated secret to send to the friend
*/
@nullable byte[] acceptPairingRequest(String friendId, in byte[] publicKey);
/**
* Accept a pairing response from a friend
* @param friendId
* @param encapsulatedSecret the encapsulated secret received from the friend
* @return true if the pairing was successful
*/
boolean acceptPairingResponse(String friendId, in byte[] encapsulatedSecret);
boolean friendKeyExists(String friendId);
@nullable EncryptionResult encryptMessage(String friendId, in byte[] message);
@nullable byte[] decryptMessage(String friendId, in byte[] message, in byte[] iv);
}

View File

@ -0,0 +1,6 @@
package me.rhunk.snapenhance.bridge.e2ee;
parcelable EncryptionResult {
byte[] ciphertext;
byte[] iv;
}

View File

@ -44,6 +44,7 @@
"disabled": "Disabled" "disabled": "Disabled"
}, },
"social": { "social": {
"e2ee_title": "End-to-End Encryption",
"rules_title": "Rules", "rules_title": "Rules",
"participants_text": "{count} participants", "participants_text": "{count} participants",
"not_found": "Not found", "not_found": "Not found",
@ -98,8 +99,8 @@
"hide_chat_feed": { "hide_chat_feed": {
"name": "Hide from Chat feed" "name": "Hide from Chat feed"
}, },
"aes_encryption": { "e2e_encryption": {
"name": "Use AES Encryption" "name": "Use E2E Encryption"
}, },
"pin_conversation": { "pin_conversation": {
"name": "Pin Conversation" "name": "Pin Conversation"
@ -519,9 +520,9 @@
"name": "No Friend Score Delay", "name": "No Friend Score Delay",
"description": "Removes the delay when viewing a Friends Score" "description": "Removes the delay when viewing a Friends Score"
}, },
"use_message_aes_encryption": { "e2e_encryption": {
"name": "Use AES Encryption", "name": "End-To-End Encryption",
"description": "Encrypts your messages using AES\nMessages are only readable by other SnapEnhance users" "description": "Encrypts your messages with AES using a shared secret key\nMake sure to save your key somewhere safe!"
}, },
"add_friend_source_spoof": { "add_friend_source_spoof": {
"name": "Add Friend Source Spoof", "name": "Add Friend Source Spoof",
@ -565,7 +566,7 @@
"auto_save": "\uD83D\uDCAC Auto Save Messages", "auto_save": "\uD83D\uDCAC Auto Save Messages",
"stealth": "\uD83D\uDC7B Stealth Mode", "stealth": "\uD83D\uDC7B Stealth Mode",
"conversation_info": "\uD83D\uDC64 Conversation Info", "conversation_info": "\uD83D\uDC64 Conversation Info",
"aes_encryption": "\uD83D\uDD12 Use AES Encryption" "e2e_encryption": "\uD83D\uDD12 Use E2E Encryption"
}, },
"path_format": { "path_format": {
"create_author_folder": "Create folder for each author", "create_author_folder": "Create folder for each author",

View File

@ -14,6 +14,7 @@ import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.BridgeInterface import me.rhunk.snapenhance.bridge.BridgeInterface
import me.rhunk.snapenhance.bridge.DownloadCallback import me.rhunk.snapenhance.bridge.DownloadCallback
import me.rhunk.snapenhance.bridge.SyncCallback import me.rhunk.snapenhance.bridge.SyncCallback
import me.rhunk.snapenhance.bridge.e2ee.E2eeInterface
import me.rhunk.snapenhance.bridge.scripting.IScripting import me.rhunk.snapenhance.bridge.scripting.IScripting
import me.rhunk.snapenhance.core.BuildConfig import me.rhunk.snapenhance.core.BuildConfig
import me.rhunk.snapenhance.core.bridge.types.BridgeFileType import me.rhunk.snapenhance.core.bridge.types.BridgeFileType
@ -145,6 +146,8 @@ class BridgeClient(
fun getScriptingInterface(): IScripting = service.getScriptingInterface() fun getScriptingInterface(): IScripting = service.getScriptingInterface()
fun getE2eeInterface(): E2eeInterface = service.getE2eeInterface()
fun openSettingsOverlay() = service.openSettingsOverlay() fun openSettingsOverlay() = service.openSettingsOverlay()
fun closeSettingsOverlay() = service.closeSettingsOverlay() fun closeSettingsOverlay() = service.closeSettingsOverlay()
} }

View File

@ -12,7 +12,7 @@ class Experimental : ConfigContainer() {
val meoPasscodeBypass = boolean("meo_passcode_bypass") val meoPasscodeBypass = boolean("meo_passcode_bypass")
val unlimitedMultiSnap = boolean("unlimited_multi_snap") { addNotices(FeatureNotice.BAN_RISK)} val unlimitedMultiSnap = boolean("unlimited_multi_snap") { addNotices(FeatureNotice.BAN_RISK)}
val noFriendScoreDelay = boolean("no_friend_score_delay") val noFriendScoreDelay = boolean("no_friend_score_delay")
val useMessageAESEncryption = boolean("use_message_aes_encryption") val useE2EEncryption = boolean("e2e_encryption")
val hiddenSnapchatPlusFeatures = boolean("hidden_snapchat_plus_features") { addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) } val hiddenSnapchatPlusFeatures = boolean("hidden_snapchat_plus_features") { addNotices(FeatureNotice.BAN_RISK, FeatureNotice.UNSTABLE) }
val addFriendSourceSpoof = unique("add_friend_source_spoof", val addFriendSourceSpoof = unique("add_friend_source_spoof",
"added_by_username", "added_by_username",

View File

@ -5,11 +5,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams import android.view.ViewGroup.LayoutParams
import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent import me.rhunk.snapenhance.core.event.events.impl.*
import me.rhunk.snapenhance.core.event.events.impl.NetworkApiRequestEvent
import me.rhunk.snapenhance.core.event.events.impl.OnSnapInteractionEvent
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.event.events.impl.SnapWidgetBroadcastReceiveEvent
import me.rhunk.snapenhance.core.util.ktx.getObjectField import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.core.util.ktx.setObjectField import me.rhunk.snapenhance.core.util.ktx.setObjectField
import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper import me.rhunk.snapenhance.core.util.snap.SnapWidgetBroadcastReceiverHelper
@ -18,11 +14,48 @@ import me.rhunk.snapenhance.data.wrapper.impl.MessageDestinations
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hook import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.hook.hookConstructor
import me.rhunk.snapenhance.manager.Manager import me.rhunk.snapenhance.manager.Manager
class EventDispatcher( class EventDispatcher(
private val context: ModContext private val context: ModContext
) : Manager { ) : Manager {
private fun findClass(name: String) = context.androidContext.classLoader.loadClass(name)
private fun hookViewBinder() {
val cachedHooks = mutableListOf<String>()
val viewBinderMappings = runCatching { context.mappings.getMappedMap("ViewBinder") }.getOrNull() ?: return
fun cacheHook(clazz: Class<*>, block: Class<*>.() -> Unit) {
if (!cachedHooks.contains(clazz.name)) {
clazz.block()
cachedHooks.add(clazz.name)
}
}
findClass(viewBinderMappings["class"].toString()).hookConstructor(HookStage.AFTER) { methodParam ->
cacheHook(
methodParam.thisObject<Any>()::class.java
) {
hook(viewBinderMappings["bindMethod"].toString(), HookStage.AFTER) bindViewMethod@{ param ->
val instance = param.thisObject<Any>()
val view = instance::class.java.methods.first {
it.name == viewBinderMappings["getViewMethod"].toString()
}.invoke(instance) as? View ?: return@bindViewMethod
context.event.post(
BindViewEvent(
prevModel = param.arg(0),
nextModel = param.argNullable(1),
view = view
)
)
}
}
}
}
override fun init() { override fun init() {
context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param -> context.classCache.conversationManager.hook("sendMessageWithContent", HookStage.BEFORE) { param ->
context.event.post(SendMessageWithContentEvent( context.event.post(SendMessageWithContentEvent(
@ -113,5 +146,7 @@ class EventDispatcher(
if (event.canceled) param.setResult(null) if (event.canceled) param.setResult(null)
} }
} }
hookViewBinder()
} }
} }

View File

@ -0,0 +1,26 @@
package me.rhunk.snapenhance.core.event.events.impl
import android.view.View
import me.rhunk.snapenhance.core.event.Event
class BindViewEvent(
val prevModel: Any,
val nextModel: Any?,
val view: View
): Event() {
fun chatMessage(block: (conversationId: String, messageId: String) -> Unit) {
val prevModelToString = prevModel.toString()
if (!prevModelToString.startsWith("ChatViewModel")) return
prevModelToString.substringAfter("messageId=").substringBefore(",").split(":").apply {
if (size != 3) return
block(this[0], this[2])
}
}
fun friendFeedItem(block: (conversationId: String) -> Unit) {
val prevModelToString = nextModel.toString()
if (!prevModelToString.startsWith("FriendFeedItemViewModel")) return
val conversationId = prevModelToString.substringAfter("conversationId: ").substringBefore("\n")
block(conversationId)
}
}

View File

@ -35,7 +35,7 @@ enum class MessagingRuleType(
STEALTH("stealth", true), STEALTH("stealth", true),
AUTO_SAVE("auto_save", true), AUTO_SAVE("auto_save", true),
HIDE_CHAT_FEED("hide_chat_feed", false, showInFriendMenu = false), HIDE_CHAT_FEED("hide_chat_feed", false, showInFriendMenu = false),
AES_ENCRYPTION("aes_encryption", false), E2E_ENCRYPTION("e2e_encryption", false),
PIN_CONVERSATION("pin_conversation", false, showInFriendMenu = false); PIN_CONVERSATION("pin_conversation", false, showInFriendMenu = false);
fun translateOptionKey(optionKey: String): String { fun translateOptionKey(optionKey: String): String {

View File

@ -166,4 +166,13 @@ class MessageSender(
.override("onError", callback = { onError(it.arg(0)) }) .override("onError", callback = { onError(it.arg(0)) })
.build()) .build())
} }
fun sendCustomChatMessage(conversations: List<SnapUUID>, contentType: ContentType, message: ProtoWriter.() -> Unit, onError: (Any) -> Unit = {}, onSuccess: () -> Unit = {}) {
internalSendMessage(conversations, createLocalMessageContentTemplate(contentType, ProtoWriter().apply {
message()
}.toByteArray(), savePolicy = "LIFETIME"), CallbackBuilder(sendMessageCallback)
.override("onSuccess", callback = { onSuccess() })
.override("onError", callback = { onError(it.arg(0)) })
.build())
}
} }

View File

@ -1,161 +0,0 @@
package me.rhunk.snapenhance.features.impl.experiments
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent
import me.rhunk.snapenhance.core.messaging.MessagingRuleType
import me.rhunk.snapenhance.core.messaging.RuleState
import me.rhunk.snapenhance.core.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.core.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.util.protobuf.ProtoWriter
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.wrapper.impl.Message
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.MessagingRuleFeature
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hookConstructor
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.random.Random
/*
To prevent snapchat from using fidelius, snaps are spoofed to external media and chats into status before it's sent to the native.
When the CreateContentMessage request is sent to the server, the content is encrypted
*/
//TODO: RSA encryption
class AESMessageEncryption : MessagingRuleFeature(
"AESMessageEncryption",
MessagingRuleType.AES_ENCRYPTION,
loadParams = FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC
) {
private val key = intArrayOf(
0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f,
0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f,
0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f,
0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f, 0x6f
).map { it.toByte() }.toByteArray()
private val isEnabled get() = context.config.experimental.useMessageAESEncryption.get()
private fun useCipher(input: ByteArray, iv: ByteArray, decrypt: Boolean = false): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(if (decrypt) Cipher.DECRYPT_MODE else Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
return cipher.doFinal(input)
}
private fun fixContentType(contentType: ContentType, message: ProtoReader): ContentType {
return when {
contentType == ContentType.EXTERNAL_MEDIA && message.containsPath(11) -> {
ContentType.SNAP
}
contentType == ContentType.SHARE && message.containsPath(2) -> {
ContentType.CHAT
}
else -> contentType
}
}
override fun asyncInit() {
// trick to disable fidelius encryption
context.event.subscribe(SendMessageWithContentEvent::class, { isEnabled }) { param ->
val messageContent = param.messageContent
val destinations = param.destinations
if (destinations.conversations.size != 1 || destinations.stories.isNotEmpty()) return@subscribe
if (!getState(destinations.conversations.first().toString())) return@subscribe
if (messageContent.contentType == ContentType.SNAP) {
messageContent.contentType = ContentType.EXTERNAL_MEDIA
}
if (messageContent.contentType == ContentType.CHAT) {
messageContent.contentType = ContentType.SHARE
}
}
}
override fun init() {
context.event.subscribe(UnaryCallEvent::class, { isEnabled }) { event ->
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
val protoReader = ProtoReader(event.buffer)
val conversationIds = mutableListOf<SnapUUID>()
protoReader.eachBuffer(3) {
conversationIds.add(SnapUUID.fromBytes(getByteArray(1, 1, 1) ?: return@eachBuffer))
}
if (conversationIds.size != 1) return@subscribe
if (!getState(conversationIds.first().toString())) return@subscribe
val generatedIv = ByteArray(16).also { Random.nextBytes(it) }
event.buffer = ProtoEditor(event.buffer).apply {
protoReader.followPath(4) {
val contentType = fixContentType(ContentType.fromId(getVarInt(2)?.toInt() ?: -1), followPath(4) ?: return@followPath)
runCatching {
val encryptedMessage = useCipher(getByteArray(4) ?: return@followPath, generatedIv, false)
edit(4) {
//set message content type
remove(2)
addVarInt(2, contentType.id)
//set encrypted content
remove(4)
add(4) {
from(2) {
from(1) {
addBuffer(1, encryptedMessage)
addBuffer(2, generatedIv)
}
addVarInt(2, 1)
}
}
}
}.onFailure {
event.canceled = true
context.log.error("Failed to encrypt message", it)
context.longToast("Failed to encrypt message! Check logcat for more details.")
}
}
}.toByteArray()
}
context.classCache.message.hookConstructor(HookStage.AFTER, { isEnabled }) { param ->
val message = Message(param.thisObject())
val reader = ProtoReader(message.messageContent.content)
// fix content type
message.messageContent.contentType?.also {
message.messageContent.contentType = fixContentType(it, reader)
}
reader.followPath(2) {
if (getVarInt(2) != 1L) return@followPath
runCatching {
followPath(1) path@{
val encryptedMessage = getByteArray(1) ?: return@path
val iv = getByteArray(2) ?: return@path
val decryptedMessage = useCipher(encryptedMessage, iv, decrypt = true)
message.messageContent.content = decryptedMessage
}
}.onFailure {
context.log.error("Failed to decrypt message id: ${message.messageDescriptor.messageId}", it)
message.messageContent.contentType = ContentType.CHAT
message.messageContent.content = ProtoWriter().apply {
from(2) {
addString(1, "Failed to decrypt message, id=${message.messageDescriptor.messageId}. Check logcat for more details.")
}
}.toByteArray()
}
}
}
}
override fun getRuleState() = RuleState.WHITELIST
}

View File

@ -0,0 +1,442 @@
package me.rhunk.snapenhance.features.impl.experiments
import android.annotation.SuppressLint
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.view.ViewGroup.MarginLayoutParams
import android.widget.Button
import android.widget.TextView
import me.rhunk.snapenhance.core.event.events.impl.AddViewEvent
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.core.event.events.impl.SendMessageWithContentEvent
import me.rhunk.snapenhance.core.event.events.impl.UnaryCallEvent
import me.rhunk.snapenhance.core.messaging.MessagingRuleType
import me.rhunk.snapenhance.core.messaging.RuleState
import me.rhunk.snapenhance.core.util.ktx.getObjectField
import me.rhunk.snapenhance.core.util.protobuf.ProtoEditor
import me.rhunk.snapenhance.core.util.protobuf.ProtoReader
import me.rhunk.snapenhance.core.util.protobuf.ProtoWriter
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.wrapper.impl.Message
import me.rhunk.snapenhance.data.wrapper.impl.MessageContent
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.MessagingRuleFeature
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.hookConstructor
import me.rhunk.snapenhance.ui.ViewAppearanceHelper
import java.security.MessageDigest
import kotlin.random.Random
class EndToEndEncryption : MessagingRuleFeature(
"EndToEndEncryption",
MessagingRuleType.E2E_ENCRYPTION,
loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_SYNC or FeatureLoadParams.INIT_ASYNC
) {
private val isEnabled get() = context.config.experimental.useE2EEncryption.get()
private val e2eeInterface by lazy { context.bridgeClient.getE2eeInterface() }
companion object {
const val REQUEST_PK_MESSAGE_ID = 1
const val RESPONSE_SK_MESSAGE_ID = 2
const val ENCRYPTED_MESSAGE_ID = 3
}
private val pkRequests = mutableMapOf<Long, ByteArray>()
private val secretResponses = mutableMapOf<Long, ByteArray>()
private fun getE2EParticipants(conversationId: String): List<String> {
return context.database.getConversationParticipants(conversationId)?.filter { friendId -> e2eeInterface.friendKeyExists(friendId) } ?: emptyList()
}
private fun askForKeys(conversationId: String) {
val friendId = context.database.getDMOtherParticipant(conversationId) ?: run {
context.longToast("Can't find friendId for conversationId $conversationId")
return
}
val publicKey = e2eeInterface.createKeyExchange(friendId) ?: run {
context.longToast("Can't create key exchange for friendId $friendId")
return
}
context.log.verbose("created publicKey: ${publicKey.contentToString()}")
sendCustomMessage(conversationId, REQUEST_PK_MESSAGE_ID) {
addBuffer(2, publicKey)
}
}
private fun sendCustomMessage(conversationId: String, messageId: Int, message: ProtoWriter.() -> Unit) {
context.messageSender.sendCustomChatMessage(
listOf(SnapUUID.fromString(conversationId)),
ContentType.CHAT,
message = {
from(2) {
from(1) {
addVarInt(1, messageId)
addBuffer(2, ProtoWriter().apply(message).toByteArray())
}
}
}
)
}
private fun warnKeyOverwrite(friendId: String, block: () -> Unit) {
if (!e2eeInterface.friendKeyExists(friendId)) {
block()
return
}
context.mainActivity?.runOnUiThread {
val mainActivity = context.mainActivity ?: return@runOnUiThread
ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply {
setTitle("End-to-end encryption")
setMessage("WARNING: This will overwrite your existing key. You will loose access to all encrypted messages from this friend. Are you sure you want to continue?")
setPositiveButton("Yes") { _, _ ->
ViewAppearanceHelper.newAlertDialogBuilder(mainActivity).apply {
setTitle("End-to-end encryption")
setMessage("Are you REALLY sure you want to continue? This is your last chance to back out.")
setNeutralButton("Yes") { _, _ -> block() }
setPositiveButton("No") { _, _ -> }
}.show()
}
setNegativeButton("No") { _, _ -> }
}.show()
}
}
private fun handlePublicKeyRequest(conversationId: String, publicKey: ByteArray) {
val friendId = context.database.getDMOtherParticipant(conversationId) ?: run {
context.longToast("Can't find friendId for conversationId $conversationId")
return
}
warnKeyOverwrite(friendId) {
val encapsulatedSecret = e2eeInterface.acceptPairingRequest(friendId, publicKey)
if (encapsulatedSecret == null) {
context.longToast("Failed to accept public key")
return@warnKeyOverwrite
}
context.longToast("Public key successfully accepted")
sendCustomMessage(conversationId, RESPONSE_SK_MESSAGE_ID) {
addBuffer(2, encapsulatedSecret)
}
}
}
private fun handleSecretResponse(conversationId: String, secret: ByteArray) {
val friendId = context.database.getDMOtherParticipant(conversationId) ?: run {
context.longToast("Can't find friendId for conversationId $conversationId")
return
}
warnKeyOverwrite(friendId) {
context.log.verbose("handleSecretResponse, secret = $secret")
val result = e2eeInterface.acceptPairingResponse(friendId, secret)
if (!result) {
context.longToast("Failed to accept secret")
return@warnKeyOverwrite
}
context.longToast("Done! You can now exchange encrypted messages with this friend.")
}
}
private fun openManagementPopup() {
val conversationId = context.feature(Messaging::class).openedConversationUUID?.toString() ?: return
if (context.database.getDMOtherParticipant(conversationId) == null) {
context.shortToast("This menu is only available in direct messages.")
return
}
val actions = listOf(
"Initiate a new shared secret"
)
ViewAppearanceHelper.newAlertDialogBuilder(context.mainActivity!!).apply {
setTitle("End-to-end encryption")
setItems(actions.toTypedArray()) { _, which ->
when (which) {
0 -> askForKeys(conversationId)
}
}
setPositiveButton("OK") { _, _ -> }
}.show()
}
@SuppressLint("SetTextI18n")
override fun onActivityCreate() {
if (!isEnabled) return
// add button to input bar
context.event.subscribe(AddViewEvent::class) { param ->
if (param.view.toString().contains("default_input_bar")) {
(param.view as ViewGroup).addView(TextView(param.view.context).apply {
layoutParams = MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)
setOnClickListener { openManagementPopup() }
setPadding(20, 20, 20, 20)
textSize = 23f
text = "\uD83D\uDD12"
})
}
}
// hook view binder to add special buttons
val receivePublicKeyTag = Random.nextLong().toString(16)
val receiveSecretTag = Random.nextLong().toString(16)
context.event.subscribe(BindViewEvent::class) { event ->
event.chatMessage { conversationId, messageId ->
val viewGroup = event.view as ViewGroup
viewGroup.findViewWithTag<View>(receiveSecretTag)?.also {
viewGroup.removeView(it)
}
viewGroup.findViewWithTag<View>(receivePublicKeyTag)?.also {
viewGroup.removeView(it)
}
secretResponses[messageId.toLong()]?.also { secret ->
viewGroup.addView(Button(context.mainActivity!!).apply {
text = "Accept secret"
tag = receiveSecretTag
setOnClickListener {
handleSecretResponse(conversationId, secret)
}
})
}
pkRequests[messageId.toLong()]?.also { publicKey ->
viewGroup.addView(Button(context.mainActivity!!).apply {
text = "Receive public key"
tag = receivePublicKeyTag
setOnClickListener {
handlePublicKeyRequest(conversationId, publicKey)
}
})
}
}
}
}
private fun fixContentType(contentType: ContentType, message: ProtoReader): ContentType {
return when {
contentType == ContentType.EXTERNAL_MEDIA && message.containsPath(11) -> {
ContentType.SNAP
}
contentType == ContentType.SHARE && message.containsPath(2) -> {
ContentType.CHAT
}
else -> contentType
}
}
private fun hashParticipantId(participantId: String, salt: ByteArray): ByteArray {
return MessageDigest.getInstance("SHA-256").apply {
update(participantId.toByteArray())
update(salt)
}.digest()
}
private fun messageHook(conversationId: String, messageId: Long, senderId: String, messageContent: MessageContent) {
val reader = ProtoReader(messageContent.content)
fun replaceMessageText(text: String) {
messageContent.content = ProtoWriter().apply {
from(2) {
addString(1, text)
}
}.toByteArray()
}
// decrypt messages
reader.followPath(2, 1) {
val messageTypeId = getVarInt(1)?.toInt() ?: return@followPath
val isMe = context.database.myUserId == senderId
val conversationParticipants by lazy {
getE2EParticipants(conversationId)
}
if (messageTypeId == ENCRYPTED_MESSAGE_ID) {
runCatching {
replaceMessageText("Cannot find a key to decrypt this message.")
eachBuffer(2) {
val participantIdHash = getByteArray(1) ?: return@eachBuffer
val iv = getByteArray(2) ?: return@eachBuffer
val ciphertext = getByteArray(3) ?: return@eachBuffer
if (isMe) {
if (conversationParticipants.isEmpty()) return@eachBuffer
val participantId = conversationParticipants.firstOrNull { participantIdHash.contentEquals(hashParticipantId(it, iv)) } ?: return@eachBuffer
messageContent.content = e2eeInterface.decryptMessage(participantId, ciphertext, iv)
return@eachBuffer
}
if (!participantIdHash.contentEquals(hashParticipantId(context.database.myUserId, iv))) return@eachBuffer
messageContent.content = e2eeInterface.decryptMessage(senderId, ciphertext, iv)
}
// fix content type
messageContent.contentType?.also {
messageContent.contentType = fixContentType(it, reader)
}
}.onFailure {
context.log.error("Failed to decrypt message id: $messageId", it)
messageContent.contentType = ContentType.CHAT
messageContent.content = ProtoWriter().apply {
from(2) {
addString(1, "Failed to decrypt message, id=$messageId. Check logcat for more details.")
}
}.toByteArray()
}
return@followPath
}
val payload = getByteArray(2, 2) ?: return@followPath
if (senderId == context.database.myUserId) {
when (messageTypeId) {
REQUEST_PK_MESSAGE_ID -> {
replaceMessageText("[Key exchange request]")
}
RESPONSE_SK_MESSAGE_ID -> {
replaceMessageText("[Key exchange response]")
}
}
return@followPath
}
when (messageTypeId) {
REQUEST_PK_MESSAGE_ID -> {
pkRequests[messageId] = payload
replaceMessageText("You just received a public key request. Click below to accept it.")
}
RESPONSE_SK_MESSAGE_ID -> {
secretResponses[messageId] = payload
replaceMessageText("Your friend just accepted your public key. Click below to accept the secret.")
}
}
}
}
override fun asyncInit() {
if (!isEnabled) return
// trick to disable fidelius encryption
context.event.subscribe(SendMessageWithContentEvent::class) { param ->
val messageContent = param.messageContent
val destinations = param.destinations
if (destinations.conversations.size != 1 || destinations.stories.isNotEmpty()) return@subscribe
if (!getState(destinations.conversations.first().toString())) return@subscribe
if (messageContent.contentType == ContentType.SNAP) {
messageContent.contentType = ContentType.EXTERNAL_MEDIA
}
if (messageContent.contentType == ContentType.CHAT) {
messageContent.contentType = ContentType.SHARE
}
}
}
override fun init() {
if (!isEnabled) return
context.event.subscribe(UnaryCallEvent::class) { event ->
if (event.uri != "/messagingcoreservice.MessagingCoreService/CreateContentMessage") return@subscribe
val protoReader = ProtoReader(event.buffer)
val conversationIds = mutableListOf<SnapUUID>()
protoReader.eachBuffer(3) {
conversationIds.add(SnapUUID.fromBytes(getByteArray(1, 1, 1) ?: return@eachBuffer))
}
if (conversationIds.any { !getState(it.toString()) }) {
context.log.debug("Skipping encryption for conversation ids: ${conversationIds.joinToString(", ")}")
return@subscribe
}
val participantsIds = conversationIds.map { getE2EParticipants(it.toString()) }.flatten().distinct()
if (participantsIds.isEmpty()) {
context.longToast("You don't have any friends in this conversation to encrypt messages with!")
event.canceled = true
return@subscribe
}
val messageReader = protoReader.followPath(4) ?: return@subscribe
if (messageReader.getVarInt(4, 2, 1, 1) != null) {
return@subscribe
}
event.buffer = ProtoEditor(event.buffer).apply {
val contentType = fixContentType(ContentType.fromId(messageReader.getVarInt(2)?.toInt() ?: -1), messageReader.followPath(4) ?: return@apply)
val messageContent = messageReader.getByteArray(4) ?: return@apply
runCatching {
edit(4) {
//set message content type
remove(2)
addVarInt(2, contentType.id)
//set encrypted content
remove(4)
add(4) {
from(2) {
from(1) {
addVarInt(1, ENCRYPTED_MESSAGE_ID)
participantsIds.forEach { participantId ->
val encryptedMessage = e2eeInterface.encryptMessage(participantId,
messageContent
) ?: run {
context.log.error("Failed to encrypt message for $participantId")
return@forEach
}
context.log.debug("encrypted message size = ${encryptedMessage.ciphertext.size} for $participantId")
from(2) {
// participantId is hashed with iv to prevent leaking it when sending to multiple conversations
addBuffer(1, hashParticipantId(participantId, encryptedMessage.iv))
addBuffer(2, encryptedMessage.iv)
addBuffer(3, encryptedMessage.ciphertext)
}
}
}
}
}
}
}.onFailure {
event.canceled = true
context.log.error("Failed to encrypt message", it)
context.longToast("Failed to encrypt message! Check logcat for more details.")
}
}.toByteArray()
}
context.classCache.message.hookConstructor(HookStage.AFTER) { param ->
val message = Message(param.thisObject())
val conversationId = message.messageDescriptor.conversationId.toString()
messageHook(
conversationId = conversationId,
messageId = message.messageDescriptor.messageId,
senderId = message.senderId.toString(),
messageContent = message.messageContent
)
message.messageContent.instanceNonNull()
.getObjectField("mQuotedMessage")
?.getObjectField("mContent")
?.also { quotedMessage ->
messageHook(
conversationId = conversationId,
messageId = quotedMessage.getObjectField("mMessageId")?.toString()?.toLong() ?: return@also,
senderId = SnapUUID(quotedMessage.getObjectField("mSenderId")).toString(),
messageContent = MessageContent(quotedMessage)
)
}
}
}
override fun getRuleState() = RuleState.WHITELIST
}

View File

@ -2,9 +2,9 @@ package me.rhunk.snapenhance.features.impl.spying
import android.graphics.drawable.ColorDrawable import android.graphics.drawable.ColorDrawable
import android.os.DeadObjectException import android.os.DeadObjectException
import android.view.View
import com.google.gson.JsonObject import com.google.gson.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import me.rhunk.snapenhance.core.event.events.impl.BindViewEvent
import me.rhunk.snapenhance.data.ContentType import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.MessageState import me.rhunk.snapenhance.data.MessageState
import me.rhunk.snapenhance.data.wrapper.impl.Message import me.rhunk.snapenhance.data.wrapper.impl.Message
@ -12,8 +12,6 @@ import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.hook.hook
import me.rhunk.snapenhance.hook.hookConstructor
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTime import kotlin.time.measureTime
@ -33,6 +31,7 @@ class MessageLogger : Feature("MessageLogger",
companion object { companion object {
const val PREFETCH_MESSAGE_COUNT = 20 const val PREFETCH_MESSAGE_COUNT = 20
const val PREFETCH_FEED_COUNT = 20 const val PREFETCH_FEED_COUNT = 20
const val DELETED_MESSAGE_COLOR = 0x2Eb71c1c
} }
private val isEnabled get() = context.config.messaging.messageLogger.get() private val isEnabled get() = context.config.messaging.messageLogger.get()
@ -154,40 +153,16 @@ class MessageLogger : Feature("MessageLogger",
override fun onActivityCreate() { override fun onActivityCreate() {
if (!isEnabled) return if (!isEnabled) return
val viewBinderMappings = context.mappings.getMappedMap("ViewBinder") context.event.subscribe(BindViewEvent::class) { event ->
val cachedHooks = mutableListOf<String>() event.chatMessage { conversationId, messageId ->
val foreground = event.view.foreground
fun cacheHook(clazz: Class<*>, block: Class<*>.() -> Unit) { if (foreground is ColorDrawable && foreground.color == DELETED_MESSAGE_COLOR) {
if (!cachedHooks.contains(clazz.name)) { event.view.foreground = null
clazz.block()
cachedHooks.add(clazz.name)
}
}
findClass(viewBinderMappings["class"].toString()).hookConstructor(HookStage.AFTER) { methodParam ->
cacheHook(
methodParam.thisObject<Any>()::class.java
) {
hook(viewBinderMappings["bindMethod"].toString(), HookStage.BEFORE) bindViewMethod@{ param ->
val instance = param.thisObject<Any>()
val model1 = param.arg<Any>(0).toString().also {
if (!it.startsWith("ChatViewModel")) return@bindViewMethod
}
val messageId = model1.substringAfter("messageId=").substringBefore(",").split(":").let {
it[0] to it[2]
}
getServerMessageIdentifier(messageId.first, messageId.second.toLong())?.let { serverMessageId ->
if (!deletedMessageCache.contains(serverMessageId)) return@bindViewMethod
} ?: return@bindViewMethod
val view = instance::class.java.methods.first {
it.name == viewBinderMappings["getViewMethod"].toString()
}.invoke(instance) as? View ?: return@bindViewMethod
view.foreground = ColorDrawable(0x1E90313e) // red with alpha
} }
getServerMessageIdentifier(conversationId, messageId.toLong())?.let { serverMessageId ->
if (!deletedMessageCache.contains(serverMessageId)) return@chatMessage
} ?: return@chatMessage
event.view.foreground = ColorDrawable(DELETED_MESSAGE_COLOR) // red with alpha
} }
} }
} }

View File

@ -2,7 +2,6 @@ package me.rhunk.snapenhance.manager.impl
import me.rhunk.snapenhance.ModContext import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.core.Logger import me.rhunk.snapenhance.core.Logger
import me.rhunk.snapenhance.features.impl.experiments.AESMessageEncryption
import me.rhunk.snapenhance.features.Feature import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.MessagingRuleFeature import me.rhunk.snapenhance.features.MessagingRuleFeature
@ -54,7 +53,7 @@ class FeatureManager(private val context: ModContext) : Manager {
override fun init() { override fun init() {
register( register(
AESMessageEncryption::class, EndToEndEncryption::class,
ScopeSync::class, ScopeSync::class,
Messaging::class, Messaging::class,
MediaDownloader::class, MediaDownloader::class,

View File

@ -1,5 +1,6 @@
[versions] [versions]
agp = "8.1.1" agp = "8.1.1"
bcprov-jdk18on = "1.76"
coil-compose = "2.4.0" coil-compose = "2.4.0"
junit = "4.13.2" junit = "4.13.2"
kotlin = "1.8.22" kotlin = "1.8.22"
@ -26,6 +27,7 @@ androidx-material-icons-core = { module = "androidx.compose.material:material-ic
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons-extended" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material-icons-extended" }
androidx-material-ripple = { module = "androidx.compose.material:material-ripple", version.ref = "material-icons-core" } androidx-material-ripple = { module = "androidx.compose.material:material-ripple", version.ref = "material-icons-core" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov-jdk18on" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil-compose" }
coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil-compose" } coil-video = { module = "io.coil-kt:coil-video", version.ref = "coil-compose" }
coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }