mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-06-13 05:37:48 +02:00
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:
@ -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")
|
||||||
|
@ -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 {
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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<*>) {
|
||||||
|
@ -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();
|
||||||
|
@ -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);
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package me.rhunk.snapenhance.bridge.e2ee;
|
||||||
|
|
||||||
|
parcelable EncryptionResult {
|
||||||
|
byte[] ciphertext;
|
||||||
|
byte[] iv;
|
||||||
|
}
|
@ -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",
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
@ -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
|
|
||||||
}
|
|
@ -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
|
||||||
|
}
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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" }
|
||||||
|
Reference in New Issue
Block a user