initial commit

This commit is contained in:
rhunk
2023-05-15 00:37:29 +02:00
commit 0650686667
110 changed files with 6056 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

28
README.md Normal file
View File

@ -0,0 +1,28 @@
# SnapEnhance
A xposed mod to enhance the Snapchat experience
The project is currently in development, so expect bugs and crashes. Feel free to open an issue if you find any bug.
## build
1. make sure you have the latest version of [LSPosed](https://github.com/LSPosed/LSPosed)
2. clone this repo using ``git clone``
3. run ``./gradlew assembleDebug``
4. install the apk using adb ``adb install -r app/build/outputs/apk/debug/app-debug.apk``
## features
- media downloader (+ overlay merging)
- message auto save
- message in notifications
- message logger
- snapchat plus features
- anonymous story viewing
- stealth mode
- screenshot detection bypass
- conversation preview
- prevent status notifications
- UI tweaks (remove call button, record button, ...)
- ad blocker
## todo
- [] localization
- [] ui improvements
- [] snap splitting

16
app/.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

74
app/build.gradle Normal file
View File

@ -0,0 +1,74 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
def appVersionName = "0.0.1"
def appVersionCode = 1
android {
compileSdk 32
defaultConfig {
applicationId "me.rhunk.snapenhance"
minSdk 29
targetSdk 32
versionCode appVersionCode
versionName appVersionName
multiDexEnabled true
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
//keep arm64-v8a native libs
packagingOptions {
exclude "META-INF/**"
exclude 'lib/x86/**'
exclude 'lib/x86_64/**'
exclude 'lib/armeabi-v7a/**'
}
kotlinOptions {
jvmTarget = '1.8'
}
}
afterEvaluate {
//auto install for debug purpose
getTasks().getByPath(":app:assembleDebug").doLast {
try {
println "Killing Snapchat"
exec {
commandLine "adb", "shell", "am", "force-stop", "com.snapchat.android"
}
println "Installing debug build"
exec() {
commandLine "adb", "install", "-r", "-d", "${buildDir}/outputs/apk/debug/app-debug.apk"
}
println "Starting Snapchat"
exec {
commandLine "adb", "shell", "am", "start", "com.snapchat.android"
}
} catch (Throwable t) {
println "Failed to install debug build"
t.printStackTrace()
}
}
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1'
compileOnly files('libs/LSPosed-api-1.0-SNAPSHOT.jar')
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.arthenica:ffmpeg-kit-min-gpl:5.1'
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="me.rhunk.snapenhance">
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application
android:usesCleartextTraffic="true"
android:label="@string/app_name"
tools:targetApi="31">
<meta-data
android:name="xposedmodule"
android:value="true" />
<meta-data
android:name="xposeddescription"
android:value="Enhanced Snapchat" />
<meta-data
android:name="xposedminversion"
android:value="53" />
<meta-data
android:name="xposedscope"
android:resource="@array/sc_scope" />
<service
android:name=".bridge.service.BridgeService"
android:exported="true">
</service>
<activity
android:theme="@android:style/Theme.NoDisplay"
android:name=".bridge.service.MainActivity"
android:exported="true"
android:excludeFromRecents="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1 @@
me.rhunk.snapenhance.XposedLoader

View File

@ -0,0 +1,14 @@
package me.rhunk.snapenhance;
import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
public class XposedLoader implements IXposedHookLoadPackage {
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam packageParam) throws Throwable {
if (!packageParam.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return;
new SnapEnhance();
}
}

View File

@ -0,0 +1,21 @@
package me.rhunk.snapenhance
object Constants {
const val TAG = "SnapEnhance"
const val SNAPCHAT_PACKAGE_NAME = "com.snapchat.android"
const val VIEW_INJECTED_CODE = 0x7FFFFF02
const val VIEW_DRAWER = 0x7FFFFF03
val ARROYO_NOTE_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 6, 1, 1)
val ARROYO_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 11, 5, 1, 1)
val MESSAGE_SNAP_ENCRYPTION_PROTO_PATH = intArrayOf(11, 5, 1, 1)
val ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH = intArrayOf(4, 4, 3, 3, 5, 1, 1)
val ARROYO_STRING_CHAT_MESSAGE_PROTO = intArrayOf(4, 4, 2, 1)
val ARROYO_URL_KEY_PROTO_PATH = intArrayOf(4, 5, 1, 3, 2, 2)
const val ARROYO_ENCRYPTION_PROTO_INDEX = 19
const val ARROYO_ENCRYPTION_PROTO_INDEX_V2 = 4
const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}

View File

@ -0,0 +1,42 @@
package me.rhunk.snapenhance
import android.util.Log
import de.robv.android.xposed.XposedBridge
object Logger {
private const val TAG = "SnapEnhance"
fun log(message: Any?) {
Log.i(TAG, message.toString())
}
fun debug(message: Any?) {
if (!BuildConfig.DEBUG) return
Log.d(TAG, message.toString())
}
fun error(throwable: Throwable) {
Log.e(TAG, "",throwable)
}
fun error(message: Any?) {
Log.e(TAG, message.toString())
}
fun error(message: Any?, throwable: Throwable) {
Log.e(TAG, message.toString(), throwable)
}
fun xposedLog(message: Any?) {
XposedBridge.log(message.toString())
}
fun xposedLog(message: Any?, throwable: Throwable?) {
XposedBridge.log(message.toString())
XposedBridge.log(throwable)
}
fun xposedLog(throwable: Throwable) {
XposedBridge.log(throwable)
}
}

View File

@ -0,0 +1,96 @@
package me.rhunk.snapenhance
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.widget.Toast
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import me.rhunk.snapenhance.bridge.client.BridgeClient
import me.rhunk.snapenhance.database.DatabaseAccess
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.manager.impl.ConfigManager
import me.rhunk.snapenhance.manager.impl.FeatureManager
import me.rhunk.snapenhance.manager.impl.MappingManager
import me.rhunk.snapenhance.manager.impl.TranslationManager
import me.rhunk.snapenhance.util.download.DownloadServer
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import kotlin.reflect.KClass
import kotlin.system.exitProcess
class ModContext {
private val executorService: ExecutorService = Executors.newCachedThreadPool()
lateinit var androidContext: Context
var mainActivity: Activity? = null
val gson: Gson = GsonBuilder().create()
val bridgeClient = BridgeClient(this)
val translation = TranslationManager(this)
val features = FeatureManager(this)
val mappings = MappingManager(this)
val config = ConfigManager(this)
val database = DatabaseAccess(this)
val downloadServer = DownloadServer(this)
val classCache get() = SnapEnhance.classCache
val resources: Resources get() = androidContext.resources
fun <T : Feature> feature(featureClass: KClass<T>): T {
return features.get(featureClass)!!
}
fun runOnUiThread(runnable: () -> Unit) {
Handler(Looper.getMainLooper()).post {
runCatching(runnable).onFailure {
Logger.xposedLog("UI thread runnable failed", it)
}
}
}
fun executeAsync(runnable: () -> Unit) {
executorService.submit {
runCatching {
runnable()
}.onFailure {
Logger.xposedLog("Async task failed", it)
}
}
}
fun shortToast(message: Any) {
runOnUiThread {
Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show()
}
}
fun longToast(message: Any) {
runOnUiThread {
Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show()
}
}
fun restartApp() {
androidContext.packageManager.getLaunchIntentForPackage(
Constants.SNAPCHAT_PACKAGE_NAME
)?.let {
val intent = Intent.makeRestartActivityTask(it.component)
androidContext.startActivity(intent)
Runtime.getRuntime().exit(0)
}
}
fun softRestartApp() {
exitProcess(0)
}
fun forceCloseApp() {
Process.killProcess(Process.myPid())
exitProcess(1)
}
}

View File

@ -0,0 +1,65 @@
package me.rhunk.snapenhance
import android.app.Activity
import android.app.Application
import android.content.Context
import me.rhunk.snapenhance.data.SnapClassCache
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
class SnapEnhance {
companion object {
lateinit var classLoader: ClassLoader
val classCache: SnapClassCache by lazy {
SnapClassCache(classLoader)
}
}
private val appContext = ModContext()
init {
Hooker.hook(Application::class.java, "attach", HookStage.BEFORE) { param ->
appContext.androidContext = param.arg<Context>(0).also {
classLoader = it.classLoader
}
appContext.bridgeClient.start { bridgeResult ->
if (!bridgeResult) {
Logger.xposedLog("Cannot connect to bridge service")
appContext.restartApp()
return@start
}
runCatching {
init()
}.onFailure {
Logger.xposedLog("Failed to initialize", it)
}
}
}
Hooker.hook(Activity::class.java, "onCreate", HookStage.AFTER) {
val activity = it.thisObject() as Activity
if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook
val isMainActivityNotNull = appContext.mainActivity != null
appContext.mainActivity = activity
if (isMainActivityNotNull) return@hook
onActivityCreate()
}
}
private fun init() {
val time = System.currentTimeMillis()
with(appContext) {
translation.init()
config.init()
mappings.init()
features.init()
}
Logger.debug("initialized in ${System.currentTimeMillis() - time} ms")
}
private fun onActivityCreate() {
with(appContext) {
features.onActivityCreate()
}
}
}

View File

@ -0,0 +1,238 @@
package me.rhunk.snapenhance.bridge.client
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.*
import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.Logger.log
import me.rhunk.snapenhance.Logger.xposedLog
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.common.BridgeMessage
import me.rhunk.snapenhance.bridge.common.BridgeMessageType
import me.rhunk.snapenhance.bridge.common.impl.*
import me.rhunk.snapenhance.bridge.service.BridgeService
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import kotlin.reflect.KClass
import kotlin.system.exitProcess
class BridgeClient(
private val context: ModContext
) : ServiceConnection {
private val handlerThread = HandlerThread("BridgeClient")
private lateinit var messenger: Messenger
private lateinit var future: CompletableFuture<Boolean>
fun start(callback: (Boolean) -> Unit = {}) {
this.future = CompletableFuture()
this.handlerThread.start()
with(context.androidContext) {
val intent = Intent()
.setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name)
bindService(
intent,
Context.BIND_AUTO_CREATE,
Executors.newSingleThreadExecutor(),
this@BridgeClient
)
}
callback(future.get())
}
private fun handleResponseMessage(
msg: Message,
future: CompletableFuture<BridgeMessage>
) {
val message: BridgeMessage = when (BridgeMessageType.fromValue(msg.what)) {
BridgeMessageType.FILE_ACCESS_RESULT -> FileAccessResult()
BridgeMessageType.DOWNLOAD_CONTENT_RESULT -> DownloadContentResult()
BridgeMessageType.MESSAGE_LOGGER_RESULT -> MessageLoggerResult()
else -> {
log("Unknown message type: ${msg.what}")
null
}
} ?: return
with(message) {
read(msg.data)
future.complete(this)
}
}
@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
private fun <T : BridgeMessage> sendMessage(
messageType: BridgeMessageType,
message: BridgeMessage,
resultType: KClass<T>? = null
): T {
val future = CompletableFuture<BridgeMessage>()
val replyMessenger = Messenger(object : Handler(handlerThread.looper) {
override fun handleMessage(msg: Message) {
handleResponseMessage(msg, future)
}
})
runCatching {
with(Message.obtain()) {
what = messageType.value
replyTo = replyMessenger
data = Bundle()
message.write(data)
messenger.send(this)
}
}
return future.get() as T
}
/**
* Create a file if it doesn't exist, and read it
*
* @param fileType the type of file to create and read
* @param defaultContent the default content to write to the file if it doesn't exist
* @return the content of the file
*/
fun createAndReadFile(
fileType: FileAccessRequest.FileType,
defaultContent: ByteArray
): ByteArray {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null),
FileAccessResult::class
).run {
if (state!!) {
return readFile(fileType)
}
writeFile(fileType, defaultContent)
return defaultContent
}
}
/**
* Read a file
*
* @param fileType the type of file to read
* @return the content of the file
*/
fun readFile(fileType: FileAccessRequest.FileType): ByteArray {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.READ, fileType, null),
FileAccessResult::class
).run {
return content!!
}
}
/**
* Write a file
*
* @param fileType the type of file to write
* @param content the content to write to the file
* @return true if the file was written successfully
*/
fun writeFile(
fileType: FileAccessRequest.FileType,
content: ByteArray?
): Boolean {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.WRITE, fileType, content),
FileAccessResult::class
).run {
return state!!
}
}
/**
* Delete a file
*
* @param fileType the type of file to delete
* @return true if the file was deleted successfully
*/
fun deleteFile(fileType: FileAccessRequest.FileType): Boolean {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.DELETE, fileType, null),
FileAccessResult::class
).run {
return state!!
}
}
/**
* Check if a file exists
*
* @param fileType the type of file to check
* @return true if the file exists
*/
fun isFileExists(fileType: FileAccessRequest.FileType): Boolean {
sendMessage(
BridgeMessageType.FILE_ACCESS_REQUEST,
FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null),
FileAccessResult::class
).run {
return state!!
}
}
/**
* Download content from a URL and save it to a file
*
* @param url the URL to download content from
* @param path the path to save the content to
* @return true if the content was downloaded successfully
*/
fun downloadContent(url: String, path: String): Boolean {
sendMessage(
BridgeMessageType.DOWNLOAD_CONTENT_REQUEST,
DownloadContentRequest(url, path),
DownloadContentResult::class
).run {
return state!!
}
}
fun getMessageLoggerMessage(id: Long): ByteArray? {
sendMessage(
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
MessageLoggerRequest(MessageLoggerRequest.Action.GET, id),
MessageLoggerResult::class
).run {
return message
}
}
fun addMessageLoggerMessage(id: Long, message: ByteArray) {
sendMessage(
BridgeMessageType.MESSAGE_LOGGER_REQUEST,
MessageLoggerRequest(MessageLoggerRequest.Action.ADD, id, message),
MessageLoggerResult::class
)
}
override fun onServiceConnected(name: ComponentName, service: IBinder) {
messenger = Messenger(service)
future.complete(true)
}
override fun onNullBinding(name: ComponentName) {
xposedLog("failed to connect to bridge service")
future.complete(false)
}
override fun onServiceDisconnected(name: ComponentName) {
context.longToast("Bridge service disconnected")
Thread.sleep(1000)
exitProcess(0)
}
}

View File

@ -0,0 +1,16 @@
package me.rhunk.snapenhance.bridge.common
import android.os.Bundle
import android.os.Message
abstract class BridgeMessage {
abstract fun write(bundle: Bundle)
abstract fun read(bundle: Bundle)
fun toMessage(what: Int): Message {
val message = Message.obtain(null, what)
message.data = Bundle()
write(message.data)
return message
}
}

View File

@ -0,0 +1,22 @@
package me.rhunk.snapenhance.bridge.common
enum class BridgeMessageType(
val value: Int = 0
) {
UNKNOWN(-1),
FILE_ACCESS_REQUEST(0),
FILE_ACCESS_RESULT(1),
DOWNLOAD_CONTENT_REQUEST(2),
DOWNLOAD_CONTENT_RESULT(3),
LOCALE_REQUEST(4),
LOCALE_RESULT(5),
MESSAGE_LOGGER_REQUEST(6),
MESSAGE_LOGGER_RESULT(7);
companion object {
fun fromValue(value: Int): BridgeMessageType {
return values().firstOrNull { it.value == value } ?: UNKNOWN
}
}
}

View File

@ -0,0 +1,20 @@
package me.rhunk.snapenhance.bridge.common.impl
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class DownloadContentRequest(
var url: String? = null,
var path: String? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putString("url", url)
bundle.putString("path", path)
}
override fun read(bundle: Bundle) {
url = bundle.getString("url")
path = bundle.getString("path")
}
}

View File

@ -0,0 +1,17 @@
package me.rhunk.snapenhance.bridge.common.impl
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class DownloadContentResult(
var state: Boolean? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putBoolean("state", state!!)
}
override fun read(bundle: Bundle) {
state = bundle.getBoolean("state")
}
}

View File

@ -0,0 +1,43 @@
package me.rhunk.snapenhance.bridge.common.impl
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class FileAccessRequest(
var action: FileAccessAction? = null,
var fileType: FileType? = null,
var content: ByteArray? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putInt("action", action!!.value)
bundle.putInt("fileType", fileType!!.value)
bundle.putByteArray("content", content)
}
override fun read(bundle: Bundle) {
action = FileAccessAction.fromValue(bundle.getInt("action"))
fileType = FileType.fromValue(bundle.getInt("fileType"))
content = bundle.getByteArray("content")
}
enum class FileType(val value: Int) {
CONFIG(0), MAPPINGS(1), STEALTH(2);
companion object {
fun fromValue(value: Int): FileType? {
return values().firstOrNull { it.value == value }
}
}
}
enum class FileAccessAction(val value: Int) {
READ(0), WRITE(1), DELETE(2), EXISTS(3);
companion object {
fun fromValue(value: Int): FileAccessAction? {
return values().firstOrNull { it.value == value }
}
}
}
}

View File

@ -0,0 +1,20 @@
package me.rhunk.snapenhance.bridge.common.impl
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class FileAccessResult(
var state: Boolean? = null,
var content: ByteArray? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putBoolean("state", state!!)
bundle.putByteArray("content", content)
}
override fun read(bundle: Bundle) {
state = bundle.getBoolean("state")
content = bundle.getByteArray("content")
}
}

View File

@ -0,0 +1,17 @@
package me.rhunk.snapenhance.bridge.common.impl
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class LocaleRequest(
var locale: String? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putString("locale", locale)
}
override fun read(bundle: Bundle) {
locale = bundle.getString("locale")
}
}

View File

@ -0,0 +1,19 @@
package me.rhunk.snapenhance.bridge.common.impl
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class LocaleResult(
var locale: String? = null,
var content: ByteArray? = null
) : BridgeMessage(){
override fun write(bundle: Bundle) {
bundle.putString("locale", locale)
bundle.putByteArray("content", content)
}
override fun read(bundle: Bundle) {
locale = bundle.getString("locale")
content = bundle.getByteArray("content")
}
}

View File

@ -0,0 +1,29 @@
package me.rhunk.snapenhance.bridge.common.impl
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class MessageLoggerRequest(
var action: Action? = null,
var messageId: Long? = null,
var message: ByteArray? = null
) : BridgeMessage(){
override fun write(bundle: Bundle) {
bundle.putString("action", action!!.name)
bundle.putLong("messageId", messageId!!)
bundle.putByteArray("message", message)
}
override fun read(bundle: Bundle) {
action = Action.valueOf(bundle.getString("action")!!)
messageId = bundle.getLong("messageId")
message = bundle.getByteArray("message")
}
enum class Action {
ADD,
GET,
CLEAR
}
}

View File

@ -0,0 +1,20 @@
package me.rhunk.snapenhance.bridge.common.impl
import android.os.Bundle
import me.rhunk.snapenhance.bridge.common.BridgeMessage
class MessageLoggerResult(
var state: Boolean? = null,
var message: ByteArray? = null
) : BridgeMessage() {
override fun write(bundle: Bundle) {
bundle.putBoolean("state", state!!)
bundle.putByteArray("message", message)
}
override fun read(bundle: Bundle) {
state = bundle.getBoolean("state")
message = bundle.getByteArray("message")
}
}

View File

@ -0,0 +1,180 @@
package me.rhunk.snapenhance.bridge.service
import android.app.DownloadManager
import android.app.Service
import android.content.*
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import android.os.*
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.bridge.common.BridgeMessageType
import me.rhunk.snapenhance.bridge.common.impl.*
import java.io.File
class BridgeService : Service() {
companion object {
const val CONFIG_FILE = "config.json"
const val MAPPINGS_FILE = "mappings.json"
const val STEALTH_FILE = "stealth.txt"
const val MESSAGE_LOGGER_DATABASE = "message_logger"
}
lateinit var messageLoggerDatabase: SQLiteDatabase
override fun onBind(intent: Intent): IBinder {
with(openOrCreateDatabase(MESSAGE_LOGGER_DATABASE, Context.MODE_PRIVATE, null)) {
messageLoggerDatabase = this
execSQL("CREATE TABLE IF NOT EXISTS messages (message_id INTEGER PRIMARY KEY, serialized_message BLOB)")
}
return Messenger(object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
runCatching {
this@BridgeService.handleMessage(msg)
}.onFailure {
Logger.error("Failed to handle message", it)
}
}
}).binder
}
private fun handleMessage(msg: Message) {
val replyMessenger = msg.replyTo
when (BridgeMessageType.fromValue(msg.what)) {
BridgeMessageType.FILE_ACCESS_REQUEST -> {
with(FileAccessRequest()) {
read(msg.data)
handleFileAccess(this) { message ->
replyMessenger.send(message)
}
}
}
BridgeMessageType.DOWNLOAD_CONTENT_REQUEST -> {
with(DownloadContentRequest()) {
read(msg.data)
handleDownloadContent(this) { message ->
replyMessenger.send(message)
}
}
}
BridgeMessageType.LOCALE_REQUEST -> {
with(LocaleRequest()) {
read(msg.data)
handleLocaleRequest(this) { message ->
replyMessenger.send(message)
}
}
}
BridgeMessageType.MESSAGE_LOGGER_REQUEST -> {
with(MessageLoggerRequest()) {
read(msg.data)
handleMessageLoggerRequest(this) { message ->
replyMessenger.send(message)
}
}
}
else -> Logger.error("Unknown message type: " + msg.what)
}
}
private fun handleMessageLoggerRequest(msg: MessageLoggerRequest, reply: (Message) -> Unit) {
when (msg.action) {
MessageLoggerRequest.Action.ADD -> {
messageLoggerDatabase.insert("messages", null, ContentValues().apply {
put("message_id", msg.messageId)
put("serialized_message", msg.message)
})
}
MessageLoggerRequest.Action.CLEAR -> {
messageLoggerDatabase.execSQL("DELETE FROM messages")
}
MessageLoggerRequest.Action.GET -> {
val messageId = msg.messageId
val cursor = messageLoggerDatabase.rawQuery("SELECT serialized_message FROM messages WHERE message_id = ?", arrayOf(messageId.toString()))
val state = cursor.moveToFirst()
val message: ByteArray? = if (state) {
cursor.getBlob(0)
} else {
null
}
cursor.close()
reply(MessageLoggerResult(state, message).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value))
}
else -> {
Logger.error(Exception("Unknown message logger action: ${msg.action}"))
}
}
reply(MessageLoggerResult(true).toMessage(BridgeMessageType.MESSAGE_LOGGER_RESULT.value))
}
private fun handleLocaleRequest(msg: LocaleRequest, reply: (Message) -> Unit) {
val locale = resources.configuration.locales[0]
Logger.log("Locale: ${locale.language}_${locale.country}")
TODO()
}
private fun handleDownloadContent(msg: DownloadContentRequest, reply: (Message) -> Unit) {
if (!msg.url!!.startsWith("http://127.0.0.1:")) return
val outputFile = File(msg.path!!)
outputFile.parentFile?.let {
if (!it.exists()) it.mkdirs()
}
val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(Uri.parse(msg.url))
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
.setDestinationUri(Uri.fromFile(outputFile))
val downloadId = downloadManager.enqueue(request)
registerReceiver(object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadId) return
unregisterReceiver(this)
reply(DownloadContentResult(true).toMessage(BridgeMessageType.DOWNLOAD_CONTENT_RESULT.value))
}
}, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE))
}
private fun handleFileAccess(msg: FileAccessRequest, reply: (Message) -> Unit) {
val file = when (msg.fileType) {
FileAccessRequest.FileType.CONFIG -> CONFIG_FILE
FileAccessRequest.FileType.MAPPINGS -> MAPPINGS_FILE
FileAccessRequest.FileType.STEALTH -> STEALTH_FILE
else -> throw Exception("Unknown file type: " + msg.fileType)
}.let { File(filesDir, it) }
val result: FileAccessResult = when (msg.action) {
FileAccessRequest.FileAccessAction.READ -> {
if (!file.exists()) {
FileAccessResult(false, null)
} else {
FileAccessResult(true, file.readBytes())
}
}
FileAccessRequest.FileAccessAction.WRITE -> {
if (!file.exists()) {
file.createNewFile()
}
file.writeBytes(msg.content!!)
FileAccessResult(true, null)
}
FileAccessRequest.FileAccessAction.DELETE -> {
if (!file.exists()) {
FileAccessResult(false, null)
} else {
file.delete()
FileAccessResult(true, null)
}
}
FileAccessRequest.FileAccessAction.EXISTS -> FileAccessResult(file.exists(), null)
else -> throw Exception("Unknown action: " + msg.action)
}
reply(result.toMessage(BridgeMessageType.FILE_ACCESS_RESULT.value))
}
}

View File

@ -0,0 +1,20 @@
package me.rhunk.snapenhance.bridge.service
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import me.rhunk.snapenhance.Constants
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.getBooleanExtra("is_from_bridge", false)) {
finish()
return
}
val intent = packageManager.getLaunchIntentForPackage(Constants.SNAPCHAT_PACKAGE_NAME)
intent!!.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
}

View File

@ -0,0 +1,58 @@
package me.rhunk.snapenhance.config
open class ConfigAccessor(
private val configMap: MutableMap<ConfigProperty, Any?>
) {
fun bool(key: ConfigProperty): Boolean {
return get(key) as Boolean
}
fun int(key: ConfigProperty): Int {
return get(key) as Int
}
fun string(key: ConfigProperty): String {
return get(key) as String
}
fun double(key: ConfigProperty): Double {
return get(key) as Double
}
fun float(key: ConfigProperty): Float {
return get(key) as Float
}
fun long(key: ConfigProperty): Long {
return get(key) as Long
}
fun short(key: ConfigProperty): Short {
return get(key) as Short
}
fun byte(key: ConfigProperty): Byte {
return get(key) as Byte
}
fun char(key: ConfigProperty): Char {
return get(key) as Char
}
@Suppress("UNCHECKED_CAST")
fun <T> list(key: ConfigProperty): List<T> {
return get(key) as List<T>
}
fun get(key: ConfigProperty): Any? {
return configMap[key]
}
fun set(key: ConfigProperty, value: Any?) {
configMap[key] = value
}
fun entries(): Set<Map.Entry<ConfigProperty, Any?>> {
return configMap.entries
}
}

View File

@ -0,0 +1,14 @@
package me.rhunk.snapenhance.config
enum class ConfigCategory(
val key: String
) {
GENERAL("general"),
SPY("spy"),
MEDIA_DOWNLOADER("media_download"),
PRIVACY("privacy"),
UI("ui"),
EXTRAS("extras"),
TWEAKS("tweaks"),
EXPERIMENTS("experiments");
}

View File

@ -0,0 +1,181 @@
package me.rhunk.snapenhance.config
import android.os.Environment
import java.io.File
enum class ConfigProperty(
val nameKey: String,
val descriptionKey: String,
val category: ConfigCategory,
val defaultValue: Any
) {
SAVE_FOLDER(
"save_folder", "description.save_folder", ConfigCategory.GENERAL,
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath + "/Snapchat",
"SnapEnhance"
).absolutePath
),
PREVENT_READ_RECEIPTS(
"prevent_read_receipts",
"description.prevent_read_receipts",
ConfigCategory.SPY,
false
),
HIDE_BITMOJI_PRESENCE(
"hide_bitmoji_presence",
"description.hide_bitmoji_presence",
ConfigCategory.SPY,
false
),
SHOW_MESSAGE_CONTENT(
"show_message_content",
"description.show_message_content",
ConfigCategory.SPY,
false
),
MESSAGE_LOGGER("message_logger", "description.message_logger", ConfigCategory.SPY, false),
MEDIA_DOWNLOADER_FEATURE(
"media_downloader_feature",
"description.media_downloader_feature",
ConfigCategory.MEDIA_DOWNLOADER,
true
),
DOWNLOAD_STORIES(
"download_stories",
"description.download_stories",
ConfigCategory.MEDIA_DOWNLOADER,
false
),
DOWNLOAD_PUBLIC_STORIES(
"download_public_stories",
"description.download_public_stories",
ConfigCategory.MEDIA_DOWNLOADER,
false
),
DOWNLOAD_SPOTLIGHT(
"download_spotlight",
"description.download_spotlight",
ConfigCategory.MEDIA_DOWNLOADER,
false
),
OVERLAY_MERGE(
"overlay_merge",
"description.overlay_merge",
ConfigCategory.MEDIA_DOWNLOADER,
true
),
DOWNLOAD_INCHAT_SNAPS(
"download_inchat_snaps",
"description.download_inchat_snaps",
ConfigCategory.MEDIA_DOWNLOADER,
true
),
DISABLE_METRICS("disable_metrics", "description.disable_metrics", ConfigCategory.PRIVACY, true),
PREVENT_SCREENSHOTS(
"prevent_screenshots",
"description.prevent_screenshots",
ConfigCategory.PRIVACY,
true
),
PREVENT_STATUS_NOTIFICATIONS(
"prevent_status_notifications",
"description.prevent_status_notifications",
ConfigCategory.PRIVACY,
true
),
ANONYMOUS_STORY_VIEW(
"anonymous_story_view",
"description.anonymous_story_view",
ConfigCategory.PRIVACY,
false
),
HIDE_TYPING_NOTIFICATION(
"hide_typing_notification",
"description.hide_typing_notification",
ConfigCategory.PRIVACY,
false
),
MENU_SLOT_ID("menu_slot_id", "description.menu_slot_id", ConfigCategory.UI, 1),
MESSAGE_PREVIEW_LENGTH(
"message_preview_length",
"description.message_preview_length",
ConfigCategory.UI,
20
),
AUTO_SAVE("auto_save", "description.auto_save", ConfigCategory.EXTRAS, false),
/*EXTERNAL_MEDIA_AS_SNAP(
"external_media_as_snap",
"description.external_media_as_snap",
ConfigCategory.EXTRAS,
false
),
CONVERSATION_EXPORT(
"conversation_export",
"description.conversation_export",
ConfigCategory.EXTRAS,
false
),*/
SNAPCHAT_PLUS("snapchat_plus", "description.snapchat_plus", ConfigCategory.EXTRAS, false),
REMOVE_VOICE_RECORD_BUTTON(
"remove_voice_record_button",
"description.remove_voice_record_button",
ConfigCategory.TWEAKS,
false
),
REMOVE_STICKERS_BUTTON(
"remove_stickers_button",
"description.remove_stickers_button",
ConfigCategory.TWEAKS,
false
),
REMOVE_COGNAC_BUTTON(
"remove_cognac_button",
"description.remove_cognac_button",
ConfigCategory.TWEAKS,
false
),
REMOVE_CALLBUTTONS(
"remove_callbuttons",
"description.remove_callbuttons",
ConfigCategory.TWEAKS,
false
),
LONG_SNAP_SENDING(
"long_snap_sending",
"description.long_snap_sending",
ConfigCategory.TWEAKS,
false
),
BLOCK_ADS("block_ads", "description.block_ads", ConfigCategory.TWEAKS, false),
STREAKEXPIRATIONINFO(
"streakexpirationinfo",
"description.streakexpirationinfo",
ConfigCategory.TWEAKS,
false
),
NEW_MAP_UI("new_map_ui", "description.new_map_ui", ConfigCategory.TWEAKS, false),
USE_DOWNLOAD_MANAGER(
"use_download_manager",
"description.use_download_manager",
ConfigCategory.EXPERIMENTS,
false
);
companion object {
fun fromNameKey(nameKey: String): ConfigProperty? {
return values().find { it.nameKey == nameKey }
}
fun sortedByCategory(): List<ConfigProperty> {
return values().sortedBy { it.category.ordinal }
}
}
}

View File

@ -0,0 +1,50 @@
package me.rhunk.snapenhance.data
enum class FileType(
val fileExtension: String? = null,
val isVideo: Boolean = false,
val isImage: Boolean = false,
val isAudio: Boolean = false
) {
GIF("gif", false, false, false),
PNG("png", false, true, false),
MP4("mp4", true, false, false),
MP3("mp3", false, false, true),
JPG("jpg", false, true, false),
ZIP("zip", false, false, false),
WEBP("webp", false, true, false),
UNKNOWN("dat", false, false, false);
companion object {
private val fileSignatures = HashMap<String, FileType>()
init {
fileSignatures["52494646"] = WEBP
fileSignatures["504b0304"] = ZIP
fileSignatures["89504e47"] = PNG
fileSignatures["00000020"] = MP4
fileSignatures["00000018"] = MP4
fileSignatures["0000001c"] = MP4
fileSignatures["ffd8ffe0"] = JPG
}
fun fromString(string: String?): FileType {
return values().firstOrNull { it.fileExtension.equals(string, ignoreCase = true) } ?: UNKNOWN
}
private fun bytesToHex(bytes: ByteArray): String {
val result = StringBuilder()
for (b in bytes) {
result.append(String.format("%02x", b))
}
return result.toString()
}
fun fromByteArray(array: ByteArray): FileType {
val headerBytes = ByteArray(16)
System.arraycopy(array, 0, headerBytes, 0, 16)
val hex = bytesToHex(headerBytes)
return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN
}
}
}

View File

@ -0,0 +1,25 @@
package me.rhunk.snapenhance.data
class SnapClassCache (
private val classLoader: ClassLoader
) {
val snapUUID by lazy { findClass("com.snapchat.client.messaging.UUID") }
val composerLocalSubscriptionStore by lazy { findClass("com.snap.plus.lib.common.ComposerLocalSubscriptionStore") }
val snapManager by lazy { findClass("com.snapchat.client.messaging.SnapManager\$CppProxy") }
val conversationManager by lazy { findClass("com.snapchat.client.messaging.ConversationManager\$CppProxy") }
val feedManager by lazy { findClass("com.snapchat.client.messaging.FeedManager\$CppProxy") }
val presenceSession by lazy { findClass("com.snapchat.talkcorev3.PresenceSession\$CppProxy") }
val message by lazy { findClass("com.snapchat.client.messaging.Message") }
val messageUpdateEnum by lazy { findClass("com.snapchat.client.messaging.MessageUpdate") }
val bestFriendWidgetProvider by lazy { findClass("com.snap.widgets.core.BestFriendsWidgetProvider") }
val unifiedGrpcService by lazy { findClass("com.snapchat.client.grpc.UnifiedGrpcService\$CppProxy") }
val networkApi by lazy { findClass("com.snapchat.client.network_api.NetworkApi\$CppProxy") }
private fun findClass(className: String): Class<*> {
return try {
classLoader.loadClass(className)
} catch (e: ClassNotFoundException) {
throw RuntimeException("Failed to find class $className", e)
}
}
}

View File

@ -0,0 +1,41 @@
package me.rhunk.snapenhance.data
enum class MessageState {
PREPARING, SENDING, COMMITTED, FAILED, CANCELING
}
enum class ContentType(val id: Int) {
UNKNOWN(-1),
SNAP(0),
CHAT(1),
EXTERNAL_MEDIA(2),
SHARE(3),
NOTE(4),
STICKER(5),
STATUS(6),
LOCATION(7),
STATUS_SAVE_TO_CAMERA_ROLL(8),
STATUS_CONVERSATION_CAPTURE_SCREENSHOT(9),
STATUS_CONVERSATION_CAPTURE_RECORD(10),
STATUS_CALL_MISSED_VIDEO(11),
STATUS_CALL_MISSED_AUDIO(12),
LIVE_LOCATION_SHARE(13),
CREATIVE_TOOL_ITEM(14),
FAMILY_CENTER_INVITE(15),
FAMILY_CENTER_ACCEPT(16),
FAMILY_CENTER_LEAVE(17);
companion object {
fun fromId(i: Int): ContentType {
return values().firstOrNull { it.id == i } ?: UNKNOWN
}
}
}
enum class PlayableSnapState {
NOTDOWNLOADED, DOWNLOADING, DOWNLOADFAILED, PLAYABLE, VIEWEDREPLAYABLE, PLAYING, VIEWEDNOTREPLAYABLE
}
enum class MediaReferenceType {
UNASSIGNED, OVERLAY, IMAGE, VIDEO, ASSET_BUNDLE, AUDIO, ANIMATED_IMAGE, FONT, WEB_VIEW_CONTENT, VIDEO_NO_AUDIO
}

View File

@ -0,0 +1,29 @@
package me.rhunk.snapenhance.data.wrapper
import de.robv.android.xposed.XposedHelpers
abstract class AbstractWrapper(
protected var instance: Any
) {
fun instance() = instance
override fun hashCode(): Int {
return instance.hashCode()
}
override fun toString(): String {
return instance.toString()
}
fun <T : Enum<*>> getEnumValue(fieldName: String, defaultValue: T): T {
val mContentType = XposedHelpers.getObjectField(instance, fieldName) as Enum<*>
return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) as T
}
@Suppress("UNCHECKED_CAST")
fun setEnumValue(fieldName: String, value: Enum<*>) {
val type = instance.javaClass.fields.find { it.name == fieldName }?.type as Class<out Enum<*>>
XposedHelpers.setObjectField(instance, fieldName, java.lang.Enum.valueOf(type, value.name))
}
}

View File

@ -0,0 +1,12 @@
package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
class Message(obj: Any) : AbstractWrapper(obj) {
val orderKey get() = instance.getObjectField("mOrderKey") as Long
val senderId get() = SnapUUID(instance.getObjectField("mSenderId"))
val messageContent get() = MessageContent(instance.getObjectField("mMessageContent"))
val messageDescriptor get() = MessageDescriptor(instance.getObjectField("mDescriptor"))
val messageMetadata get() = MessageMetadata(instance.getObjectField("mMetadata"))
}

View File

@ -0,0 +1,15 @@
package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.setObjectField
class MessageContent(obj: Any) : AbstractWrapper(obj) {
var content
get() = instance.getObjectField("mContent") as ByteArray
set(value) = instance.setObjectField("mContent", value)
var contentType
get() = getEnumValue("mContentType", ContentType.UNKNOWN)
set(value) = setEnumValue("mContentType", value)
}

View File

@ -0,0 +1,9 @@
package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
class MessageDescriptor(obj: Any) : AbstractWrapper(obj) {
val messageId: Long get() = instance.getObjectField("mMessageId") as Long
val conversationId: SnapUUID get() = SnapUUID(instance.getObjectField("mConversationId"))
}

View File

@ -0,0 +1,16 @@
package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.data.PlayableSnapState
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
class MessageMetadata(obj: Any) : AbstractWrapper(obj){
val createdAt: Long get() = instance.getObjectField("mCreatedAt") as Long
val readAt: Long get() = instance.getObjectField("mReadAt") as Long
var playableSnapState: PlayableSnapState
get() = getEnumValue("mPlayableSnapState", PlayableSnapState.PLAYABLE)
set(value) {
setEnumValue("mPlayableSnapState", value)
}
val savedBy: List<SnapUUID> = (instance.getObjectField("mSavedBy") as List<*>).map { SnapUUID(it!!) }
}

View File

@ -0,0 +1,40 @@
package me.rhunk.snapenhance.data.wrapper.impl
import me.rhunk.snapenhance.SnapEnhance
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import java.nio.ByteBuffer
import java.util.*
class SnapUUID(instance: Any) : AbstractWrapper(instance) {
private val uuidString by lazy { toUUID().toString() }
val bytes: ByteArray get() {
return instance.getObjectField("mId") as ByteArray
}
private fun toUUID(): UUID {
val buffer = ByteBuffer.wrap(bytes)
return UUID(buffer.long, buffer.long)
}
override fun toString(): String {
return uuidString
}
companion object {
fun fromString(uuid: String): SnapUUID {
return fromUUID(UUID.fromString(uuid))
}
fun fromBytes(bytes: ByteArray): SnapUUID {
val constructor = SnapEnhance.classCache.snapUUID.getConstructor(ByteArray::class.java)
return SnapUUID(constructor.newInstance(bytes))
}
fun fromUUID(uuid: UUID): SnapUUID {
val buffer = ByteBuffer.allocate(16)
buffer.putLong(uuid.mostSignificantBits)
buffer.putLong(uuid.leastSignificantBits)
return fromBytes(buffer.array())
}
}
}

View File

@ -0,0 +1,73 @@
package me.rhunk.snapenhance.data.wrapper.impl.media
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import java.io.InputStream
import java.io.OutputStream
import java.lang.reflect.Field
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class EncryptionWrapper(instance: Any) : AbstractWrapper(instance) {
fun decrypt(data: ByteArray?): ByteArray {
return newCipher(Cipher.DECRYPT_MODE).doFinal(data)
}
fun decrypt(inputStream: InputStream?): InputStream {
return CipherInputStream(inputStream, newCipher(Cipher.DECRYPT_MODE))
}
fun decrypt(outputStream: OutputStream?): OutputStream {
return CipherOutputStream(outputStream, newCipher(Cipher.DECRYPT_MODE))
}
/**
* Search for a byte[] field with the specified length
*
* @param arrayLength the length of the byte[] field
* @return the field
*/
private fun searchByteArrayField(arrayLength: Int): Field {
return instance::class.java.fields.first { f ->
try {
if (!f.type.isArray || f.type
.componentType != Byte::class.javaPrimitiveType
) return@first false
return@first (f.get(instance) as ByteArray).size == arrayLength
} catch (e: Exception) {
return@first false
}
}
}
/**
* Create a new cipher with the specified mode
*/
fun newCipher(mode: Int): Cipher {
val cipher = cipher
cipher.init(mode, SecretKeySpec(keySpec, "AES"), IvParameterSpec(ivKeyParameterSpec))
return cipher
}
/**
* Get the cipher from the encryption wrapper
*/
private val cipher: Cipher
get() = Cipher.getInstance("AES/CBC/PKCS5Padding")
/**
* Get the key spec from the encryption wrapper
*/
val keySpec: ByteArray by lazy {
searchByteArrayField(32)[instance] as ByteArray
}
/**
* Get the iv key parameter spec from the encryption wrapper
*/
val ivKeyParameterSpec: ByteArray by lazy {
searchByteArrayField(16)[instance] as ByteArray
}
}

View File

@ -0,0 +1,34 @@
package me.rhunk.snapenhance.data.wrapper.impl.media
import android.os.Parcelable
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.getObjectField
import java.lang.reflect.Field
class MediaInfo(obj: Any) : AbstractWrapper(obj) {
val uri: String
get() {
val firstStringUriField = instance.javaClass.fields.first { f: Field -> f.type == String::class.java }
return instance.getObjectField(firstStringUriField.name) as String
}
init {
var mediaInfo: Any = instance
if (mediaInfo is List<*>) {
if (mediaInfo.size == 0) {
throw RuntimeException("MediaInfo is empty")
}
mediaInfo = mediaInfo[0]!!
}
instance = mediaInfo
}
val encryption: EncryptionWrapper?
get() {
val encryptionAlgorithmField = instance.javaClass.fields.first { f: Field ->
f.type.isInterface && Parcelable::class.java.isAssignableFrom(f.type)
}
return encryptionAlgorithmField[instance]?.let { EncryptionWrapper(it) }
}
}

View File

@ -0,0 +1,21 @@
package me.rhunk.snapenhance.data.wrapper.impl.media.opera
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.ReflectionHelper
class Layer(obj: Any) : AbstractWrapper(obj) {
val paramMap: ParamMap
get() {
val layerControllerField = ReflectionHelper.searchFieldContainsToString(
instance::class.java,
instance,
"OperaPageModel"
)!!
val paramsMapHashMap = ReflectionHelper.searchFieldStartsWithToString(
layerControllerField.type,
layerControllerField[instance] as Any, "OperaPageModel"
)!!
return ParamMap(paramsMapHashMap[layerControllerField[instance]]!!)
}
}

View File

@ -0,0 +1,18 @@
package me.rhunk.snapenhance.data.wrapper.impl.media.opera
import de.robv.android.xposed.XposedHelpers
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.ReflectionHelper
import java.lang.reflect.Field
import java.util.concurrent.ConcurrentHashMap
class LayerController(obj: Any) : AbstractWrapper(obj) {
val paramMap: ParamMap
get() {
val paramMapField: Field = ReflectionHelper.searchFieldTypeInSuperClasses(
instance::class.java,
ConcurrentHashMap::class.java
) ?: throw RuntimeException("Could not find paramMap field")
return ParamMap(XposedHelpers.getObjectField(instance, paramMapField.name))
}
}

View File

@ -0,0 +1,37 @@
package me.rhunk.snapenhance.data.wrapper.impl.media.opera
import me.rhunk.snapenhance.data.wrapper.AbstractWrapper
import me.rhunk.snapenhance.util.ReflectionHelper
import me.rhunk.snapenhance.util.getObjectField
import java.lang.reflect.Field
import java.util.concurrent.ConcurrentHashMap
@Suppress("UNCHECKED_CAST")
class ParamMap(obj: Any) : AbstractWrapper(obj) {
private val paramMapField: Field by lazy {
ReflectionHelper.searchFieldTypeInSuperClasses(
instance.javaClass,
ConcurrentHashMap::class.java
)!!
}
private val concurrentHashMap: ConcurrentHashMap<Any, Any>
get() = instance.getObjectField(paramMapField.name) as ConcurrentHashMap<Any, Any>
operator fun get(key: String): Any? {
return concurrentHashMap.keys.firstOrNull{ k: Any -> k.toString() == key }?.let { concurrentHashMap[it] }
}
fun put(key: String, value: Any) {
val keyObject = concurrentHashMap.keys.firstOrNull { k: Any -> k.toString() == key } ?: key
concurrentHashMap[keyObject] = value
}
fun containsKey(key: String): Boolean {
return concurrentHashMap.keys.any { k: Any -> k.toString() == key }
}
override fun toString(): String {
return concurrentHashMap.toString()
}
}

View File

@ -0,0 +1,199 @@
package me.rhunk.snapenhance.database
import android.annotation.SuppressLint
import android.database.sqlite.SQLiteDatabase
import de.robv.android.xposed.XposedBridge
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.database.objects.*
import me.rhunk.snapenhance.manager.Manager
import java.io.File
@SuppressLint("Range")
class DatabaseAccess(private val context: ModContext) : Manager {
private val databaseLock = Any()
private val arroyoDatabase: File by lazy {
context.androidContext.getDatabasePath("arroyo.db")
}
private val mainDatabase: File by lazy {
context.androidContext.getDatabasePath("main.db")
}
private fun openMain(): SQLiteDatabase {
return SQLiteDatabase.openDatabase(
mainDatabase.absolutePath,
null,
SQLiteDatabase.OPEN_READONLY
)!!
}
private fun openArroyo(): SQLiteDatabase {
return SQLiteDatabase.openDatabase(
arroyoDatabase.absolutePath,
null,
SQLiteDatabase.OPEN_READONLY
)!!
}
private fun <T> safeDatabaseOperation(
database: SQLiteDatabase,
query: (SQLiteDatabase) -> T?
): T? {
synchronized(databaseLock) {
return runCatching {
query(database)
}.onFailure {
Logger.xposedLog("Database operation failed", it)
}.getOrNull()
}
}
private fun <T : DatabaseObject> readDatabaseObject(
obj: T,
database: SQLiteDatabase,
table: String,
where: String,
args: Array<String>
): T? {
val cursor = database.rawQuery("SELECT * FROM $table WHERE $where", args)
if (!cursor.moveToFirst()) {
cursor.close()
return null
}
try {
obj.write(cursor)
} catch (e: Throwable) {
Logger.xposedLog(e)
}
cursor.close()
return obj
}
fun getFriendFeedInfoByUserId(userId: String): FriendFeedInfo? {
return safeDatabaseOperation(openMain()) { database ->
readDatabaseObject(
FriendFeedInfo(),
database,
"FriendsFeedView",
"friendUserId = ?",
arrayOf(userId)
)
}
}
fun getFriendFeedInfoByConversationId(conversationId: String): FriendFeedInfo? {
return safeDatabaseOperation(openMain()) {
readDatabaseObject(
FriendFeedInfo(),
it,
"FriendsFeedView",
"key = ?",
arrayOf(conversationId)
)
}
}
fun getFriendInfo(userId: String): FriendInfo? {
return safeDatabaseOperation(openMain()) {
readDatabaseObject(
FriendInfo(),
it,
"FriendWithUsername",
"userId = ?",
arrayOf(userId)
)
}
}
fun getConversationMessageFromId(clientMessageId: Long): ConversationMessage? {
return safeDatabaseOperation(openArroyo()) {
readDatabaseObject(
ConversationMessage(),
it,
"conversation_message",
"client_message_id = ?",
arrayOf(clientMessageId.toString())
)
}
}
fun getDMConversationIdFromUserId(userId: String): UserConversationLink? {
return safeDatabaseOperation(openArroyo()) {
readDatabaseObject(
UserConversationLink(),
it,
"user_conversation",
"user_id = ? AND conversation_type = 0",
arrayOf(userId)
)
}
}
fun getStoryEntryFromId(storyId: String): StoryEntry? {
return safeDatabaseOperation(openMain()) {
readDatabaseObject(StoryEntry(), it, "Story", "storyId = ?", arrayOf(storyId))
}
}
fun getConversationParticipants(conversationId: String): List<String>? {
return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase ->
val cursor = arroyoDatabase.rawQuery(
"SELECT * FROM user_conversation WHERE client_conversation_id = ?",
arrayOf(conversationId)
)
if (!cursor.moveToFirst()) {
cursor.close()
return@safeDatabaseOperation emptyList()
}
val participants = mutableListOf<String>()
do {
participants.add(cursor.getString(cursor.getColumnIndex("user_id")))
} while (cursor.moveToNext())
cursor.close()
participants
}
}
fun getMyUserId(): String? {
return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase ->
val cursor = arroyoDatabase.rawQuery(buildString {
append("SELECT * FROM required_values WHERE key = 'USERID'")
}, null)
if (!cursor.moveToFirst()) {
cursor.close()
return@safeDatabaseOperation null
}
val userId = cursor.getString(cursor.getColumnIndex("value"))
cursor.close()
userId
}
}
fun getMessagesFromConversationId(
conversationId: String,
limit: Int
): List<ConversationMessage>? {
return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase ->
val cursor = arroyoDatabase.rawQuery(
"SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ?",
arrayOf(conversationId, limit.toString())
)
if (!cursor.moveToFirst()) {
cursor.close()
return@safeDatabaseOperation emptyList()
}
val messages = mutableListOf<ConversationMessage>()
do {
val message = ConversationMessage()
message.write(cursor)
messages.add(message)
} while (cursor.moveToNext())
cursor.close()
messages
}
}
}

View File

@ -0,0 +1,7 @@
package me.rhunk.snapenhance.database
import android.database.Cursor
interface DatabaseObject {
fun write(cursor: Cursor)
}

View File

@ -0,0 +1,44 @@
package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint
import android.database.Cursor
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.database.DatabaseObject
import me.rhunk.snapenhance.util.protobuf.ProtoReader
@Suppress("ArrayInDataClass")
data class ConversationMessage(
var client_conversation_id: String? = null,
var client_message_id: Int = 0,
var server_message_id: Int = 0,
var message_content: ByteArray? = null,
var is_saved: Int = 0,
var is_viewed_by_user: Int = 0,
var content_type: Int = 0,
var creation_timestamp: Long = 0,
var read_timestamp: Long = 0,
var sender_id: String? = null
) : DatabaseObject {
@SuppressLint("Range")
override fun write(cursor: Cursor) {
client_conversation_id = cursor.getString(cursor.getColumnIndex("client_conversation_id"))
client_message_id = cursor.getInt(cursor.getColumnIndex("client_message_id"))
server_message_id = cursor.getInt(cursor.getColumnIndex("server_message_id"))
message_content = cursor.getBlob(cursor.getColumnIndex("message_content"))
is_saved = cursor.getInt(cursor.getColumnIndex("is_saved"))
is_viewed_by_user = cursor.getInt(cursor.getColumnIndex("is_viewed_by_user"))
content_type = cursor.getInt(cursor.getColumnIndex("content_type"))
creation_timestamp = cursor.getLong(cursor.getColumnIndex("creation_timestamp"))
read_timestamp = cursor.getLong(cursor.getColumnIndex("read_timestamp"))
sender_id = cursor.getString(cursor.getColumnIndex("sender_id"))
}
fun getMessageAsString(): String? {
return when (ContentType.fromId(content_type)) {
ContentType.CHAT -> message_content?.let { ProtoReader(it).getString(*Constants.ARROYO_STRING_CHAT_MESSAGE_PROTO) }
else -> null
}
}
}

View File

@ -0,0 +1,33 @@
package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint
import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject
data class FriendFeedInfo(
var id: Int = 0,
var feedDisplayName: String? = null,
var participantsSize: Int = 0,
var lastInteractionTimestamp: Long = 0,
var displayTimestamp: Long = 0,
var displayInteractionType: String? = null,
var lastInteractionUserId: Int = 0,
var key: String? = null,
var friendUserId: String? = null,
var friendDisplayName: String? = null,
) : DatabaseObject {
@SuppressLint("Range")
override fun write(cursor: Cursor) {
id = cursor.getInt(cursor.getColumnIndex("_id"))
feedDisplayName = cursor.getString(cursor.getColumnIndex("feedDisplayName"))
participantsSize = cursor.getInt(cursor.getColumnIndex("participantsSize"))
lastInteractionTimestamp = cursor.getLong(cursor.getColumnIndex("lastInteractionTimestamp"))
displayTimestamp = cursor.getLong(cursor.getColumnIndex("displayTimestamp"))
displayInteractionType = cursor.getString(cursor.getColumnIndex("displayInteractionType"))
lastInteractionUserId = cursor.getInt(cursor.getColumnIndex("lastInteractionUserId"))
key = cursor.getString(cursor.getColumnIndex("key"))
friendUserId = cursor.getString(cursor.getColumnIndex("friendUserId"))
friendDisplayName = cursor.getString(cursor.getColumnIndex("friendDisplayUsername"))
}
}

View File

@ -0,0 +1,58 @@
package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint
import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject
data class FriendInfo(
var id: Int = 0,
var lastModifiedTimestamp: Long = 0,
var username: String? = null,
var userId: String? = null,
var displayName: String? = null,
var bitmojiAvatarId: String? = null,
var bitmojiSelfieId: String? = null,
var bitmojiSceneId: String? = null,
var bitmojiBackgroundId: String? = null,
var friendmojis: String? = null,
var friendmojiCategories: String? = null,
var snapScore: Int = 0,
var birthday: Long = 0,
var addedTimestamp: Long = 0,
var reverseAddedTimestamp: Long = 0,
var serverDisplayName: String? = null,
var streakLength: Int = 0,
var streakExpirationTimestamp: Long = 0,
var reverseBestFriendRanking: Int = 0,
var isPinnedBestFriend: Int = 0,
var plusBadgeVisibility: Int = 0,
var usernameForSorting: String? = null
) : DatabaseObject {
@SuppressLint("Range")
override fun write(cursor: Cursor) {
id = cursor.getInt(cursor.getColumnIndex("_id"))
lastModifiedTimestamp = cursor.getLong(cursor.getColumnIndex("_lastModifiedTimestamp"))
username = cursor.getString(cursor.getColumnIndex("username"))
userId = cursor.getString(cursor.getColumnIndex("userId"))
displayName = cursor.getString(cursor.getColumnIndex("displayName"))
bitmojiAvatarId = cursor.getString(cursor.getColumnIndex("bitmojiAvatarId"))
bitmojiSelfieId = cursor.getString(cursor.getColumnIndex("bitmojiSelfieId"))
bitmojiSceneId = cursor.getString(cursor.getColumnIndex("bitmojiSceneId"))
bitmojiBackgroundId = cursor.getString(cursor.getColumnIndex("bitmojiBackgroundId"))
friendmojis = cursor.getString(cursor.getColumnIndex("friendmojis"))
friendmojiCategories = cursor.getString(cursor.getColumnIndex("friendmojiCategories"))
snapScore = cursor.getInt(cursor.getColumnIndex("score"))
birthday = cursor.getLong(cursor.getColumnIndex("birthday"))
addedTimestamp = cursor.getLong(cursor.getColumnIndex("addedTimestamp"))
reverseAddedTimestamp = cursor.getLong(cursor.getColumnIndex("reverseAddedTimestamp"))
serverDisplayName = cursor.getString(cursor.getColumnIndex("serverDisplayName"))
streakLength = cursor.getInt(cursor.getColumnIndex("streakLength"))
streakExpirationTimestamp = cursor.getLong(cursor.getColumnIndex("streakExpiration"))
reverseBestFriendRanking = cursor.getInt(cursor.getColumnIndex("reverseBestFriendRanking"))
usernameForSorting = cursor.getString(cursor.getColumnIndex("usernameForSorting"))
if (cursor.getColumnIndex("isPinnedBestFriend") != -1) isPinnedBestFriend =
cursor.getInt(cursor.getColumnIndex("isPinnedBestFriend"))
if (cursor.getColumnIndex("plusBadgeVisibility") != -1) plusBadgeVisibility =
cursor.getInt(cursor.getColumnIndex("plusBadgeVisibility"))
}
}

View File

@ -0,0 +1,23 @@
package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint
import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject
data class StoryEntry(
var id: Int = 0,
var storyId: String? = null,
var displayName: String? = null,
var isLocal: Boolean? = null,
var userId: String? = null
) : DatabaseObject {
@SuppressLint("Range")
override fun write(cursor: Cursor) {
id = cursor.getInt(cursor.getColumnIndex("_id"))
storyId = cursor.getString(cursor.getColumnIndex("storyId"))
displayName = cursor.getString(cursor.getColumnIndex("displayName"))
isLocal = cursor.getInt(cursor.getColumnIndex("isLocal")) == 1
userId = cursor.getString(cursor.getColumnIndex("userId"))
}
}

View File

@ -0,0 +1,19 @@
package me.rhunk.snapenhance.database.objects
import android.annotation.SuppressLint
import android.database.Cursor
import me.rhunk.snapenhance.database.DatabaseObject
class UserConversationLink(
var user_id: String? = null,
var client_conversation_id: String? = null,
var conversation_type: Int = 0
) : DatabaseObject {
@SuppressLint("Range")
override fun write(cursor: Cursor) {
user_id = cursor.getString(cursor.getColumnIndex("user_id"))
client_conversation_id = cursor.getString(cursor.getColumnIndex("client_conversation_id"))
conversation_type = cursor.getInt(cursor.getColumnIndex("conversation_type"))
}
}

View File

@ -0,0 +1,62 @@
package me.rhunk.snapenhance.event
import me.rhunk.snapenhance.ModContext
import kotlin.reflect.KClass
abstract class Event {
lateinit var context: ModContext
}
interface IListener<T> {
fun handle(event: T)
}
class EventBus(
private val context: ModContext
) {
private val subscribers = mutableMapOf<KClass<out Event>, MutableList<IListener<out Event>>>()
fun <T : Event> subscribe(event: KClass<T>, listener: IListener<T>) {
if (!subscribers.containsKey(event)) {
subscribers[event] = mutableListOf()
}
subscribers[event]!!.add(listener)
}
fun <T : Event> subscribe(event: KClass<T>, listener: (T) -> Unit) {
subscribe(event, object : IListener<T> {
override fun handle(event: T) {
listener(event)
}
})
}
fun <T : Event> unsubscribe(event: KClass<T>, listener: IListener<T>) {
if (!subscribers.containsKey(event)) {
return
}
subscribers[event]!!.remove(listener)
}
fun <T : Event> post(event: T) {
if (!subscribers.containsKey(event::class)) {
return
}
event.context = context
subscribers[event::class]!!.forEach { listener ->
@Suppress("UNCHECKED_CAST")
try {
(listener as IListener<T>).handle(event)
} catch (t: Throwable) {
println("Error while handling event ${event::class.simpleName} by ${listener::class.simpleName}")
t.printStackTrace()
}
}
}
fun clear() {
subscribers.clear()
}
}

View File

@ -0,0 +1,3 @@
package me.rhunk.snapenhance.event
//TODO: addView event

View File

@ -0,0 +1,31 @@
package me.rhunk.snapenhance.features
import me.rhunk.snapenhance.ModContext
abstract class Feature(
val nameKey: String,
val loadParams: Int = FeatureLoadParams.INIT_SYNC
) {
lateinit var context: ModContext
/**
* called on the main thread when the mod initialize
*/
open fun init() {}
/**
* called on a dedicated thread when the mod initialize
*/
open fun asyncInit() {}
/**
* called when the Snapchat Activity is created
*/
open fun onActivityCreate() {}
/**
* called on a dedicated thread when the Snapchat Activity is created
*/
open fun asyncOnActivityCreate() {}
}

View File

@ -0,0 +1,11 @@
package me.rhunk.snapenhance.features
object FeatureLoadParams {
const val NO_INIT = 0
const val INIT_SYNC = 1
const val ACTIVITY_CREATE_SYNC = 2
const val INIT_ASYNC = 3
const val ACTIVITY_CREATE_ASYNC = 4
}

View File

@ -0,0 +1,67 @@
package me.rhunk.snapenhance.features.impl
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.util.setObjectField
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.util.concurrent.atomic.AtomicReference
class ConfigEnumKeys : Feature("Config enum keys", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
private fun hookAllEnums(enumClass: Class<*>, callback: (String, AtomicReference<Any>) -> Unit) {
//Enum(String, int, ?)
//or Enum(?)
val enumDataClass = enumClass.constructors[0].parameterTypes.first { clazz: Class<*> -> clazz != String::class.java && !clazz.isPrimitive }
//get the field which contains the enum data class
val enumDataField = enumClass.declaredFields.first { field: Field -> field.type == enumDataClass }
//get the field value of the enum data class (the first field of the class with the desc Object)
val objectDataField = enumDataField.type.fields.first { field: Field ->
field.type == Any::class.java && Modifier.isPublic(
field.modifiers
) && Modifier.isFinal(field.modifiers)
}
enumClass.enumConstants.forEach { enum ->
enumDataField.get(enum)?.let { enumData ->
val key = objectDataField.get(enumData)!!.toString()
val value = AtomicReference(objectDataField.get(enumData))
callback(key, value)
enumData.setObjectField(objectDataField.name, value.get())
}
}
}
override fun onActivityCreate() {
if (context.config.bool(ConfigProperty.NEW_MAP_UI)) {
hookAllEnums(context.mappings.getMappedClass("enums", "PLUS")) { key, atomicValue ->
if (key == "REDUCE_MY_PROFILE_UI_COMPLEXITY") atomicValue.set(true)
}
}
if (context.config.bool(ConfigProperty.LONG_SNAP_SENDING)) {
hookAllEnums(context.mappings.getMappedClass("enums", "ARROYO")) { key, atomicValue ->
if (key == "ENABLE_LONG_SNAP_SENDING") atomicValue.set(true)
}
}
if (context.config.bool(ConfigProperty.STREAKEXPIRATIONINFO)) {
hookAllEnums(context.mappings.getMappedClass("enums", "FRIENDS_FEED")) { key, atomicValue ->
if (key == "STREAK_EXPIRATION_INFO") atomicValue.set(true)
}
}
if (context.config.bool(ConfigProperty.BLOCK_ADS)) {
hookAllEnums(context.mappings.getMappedClass("enums", "SNAPADS")) { key, atomicValue ->
if (key == "BYPASS_AD_FEATURE_GATE") {
atomicValue.set(true)
}
if (key == "CUSTOM_AD_SERVER_URL" || key == "CUSTOM_AD_INIT_SERVER_URL" || key == "CUSTOM_AD_TRACKER_URL") {
atomicValue.set("http://127.0.0.1")
}
}
}
}
}

View File

@ -0,0 +1,71 @@
package me.rhunk.snapenhance.features.impl
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) {
lateinit var conversationManager: Any
var lastOpenedConversationUUID: SnapUUID? = null
var lastFetchConversationUserUUID: SnapUUID? = null
var lastFetchConversationUUID: SnapUUID? = null
var lastFocusedMessageId: Long = -1
override fun init() {
Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) {
conversationManager = it.thisObject()
}
}
override fun onActivityCreate() {
with(context.classCache.conversationManager) {
Hooker.hook(this, "enterConversation", HookStage.BEFORE) {
lastOpenedConversationUUID = SnapUUID(it.arg(0))
}
Hooker.hook(this, "getOneOnOneConversationIds", HookStage.BEFORE) { param ->
val conversationIds: List<Any> = param.arg(0)
if (conversationIds.isNotEmpty()) {
lastFetchConversationUserUUID = SnapUUID(conversationIds[0])
}
}
Hooker.hook(this, "exitConversation", HookStage.BEFORE) {
lastOpenedConversationUUID = null
}
Hooker.hook(this, "fetchConversation", HookStage.BEFORE) {
lastFetchConversationUUID = SnapUUID(it.arg(0))
}
}
}
override fun asyncInit() {
arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook ->
Hooker.hook(context.classCache.presenceSession, hook, HookStage.BEFORE, { context.config.bool(ConfigProperty.HIDE_BITMOJI_PRESENCE) }) {
it.setResult(null)
}
}
//get last opened snap for media downloader
Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { param ->
lastOpenedConversationUUID = SnapUUID(param.arg(1))
lastFocusedMessageId = param.arg(2)
}
Hooker.hook(context.classCache.conversationManager, "fetchMessage", HookStage.BEFORE) { param ->
lastFetchConversationUserUUID = SnapUUID((param.arg(0) as Any))
lastFocusedMessageId = param.arg(1)
}
Hooker.hook(context.classCache.conversationManager, "sendTypingNotification", HookStage.BEFORE,
{context.config.bool(ConfigProperty.HIDE_TYPING_NOTIFICATION)}) {
it.setResult(null)
}
}
}

View File

@ -0,0 +1,430 @@
package me.rhunk.snapenhance.features.impl.downloader
import android.app.AlertDialog
import android.content.DialogInterface
import android.graphics.Bitmap
import android.media.MediaScannerConnection
import android.net.Uri
import android.widget.ImageView
import com.arthenica.ffmpegkit.FFmpegKit
import de.robv.android.xposed.XposedBridge
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.Logger.xposedLog
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.FileType
import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer
import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.spy.MessageLogger
import me.rhunk.snapenhance.hook.HookAdapter
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.EncryptionUtils
import me.rhunk.snapenhance.util.PreviewUtils
import me.rhunk.snapenhance.util.download.CdnDownloader
import me.rhunk.snapenhance.util.getObjectField
import me.rhunk.snapenhance.util.protobuf.ProtoReader
import java.io.*
import java.net.HttpURLConnection
import java.net.URL
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.atomic.AtomicReference
import java.util.zip.ZipInputStream
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import kotlin.io.path.inputStream
enum class MediaType {
ORIGINAL, OVERLAY
}
class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null
private var lastSeenMapParams: ParamMap? = null
private var isFFmpegPresent: Boolean? = null
private fun canMergeOverlay(): Boolean {
if (!context.config.bool(ConfigProperty.OVERLAY_MERGE)) return false
if (isFFmpegPresent != null) {
return isFFmpegPresent!!
}
//check if ffmpeg is correctly installed
isFFmpegPresent = runCatching { FFmpegKit.execute("-version") }.isSuccess
return isFFmpegPresent!!
}
private fun createNewFilePath(hash: Int, author: String, fileType: FileType): String? {
val hexHash = Integer.toHexString(hash)
return author + "/" + hexHash + "." + fileType.fileExtension
}
private fun downloadFile(outputFile: File, content: ByteArray): Boolean {
val onDownloadComplete = {
context.shortToast(
"Saved to " + outputFile.absolutePath.replace(context.config.string(ConfigProperty.SAVE_FOLDER), "")
.substring(1)
)
}
if (!context.config.bool(ConfigProperty.USE_DOWNLOAD_MANAGER)) {
try {
val fos = FileOutputStream(outputFile)
fos.write(content)
fos.close()
MediaScannerConnection.scanFile(
context.androidContext,
arrayOf(outputFile.absolutePath),
null,
null
)
onDownloadComplete()
} catch (e: Throwable) {
Logger.xposedLog(e)
context.longToast("Failed to save file: " + e.message)
return false
}
return true
}
context.downloadServer.startFileDownload(outputFile, content) { result ->
if (result) {
onDownloadComplete()
return@startFileDownload
}
context.longToast("Failed to save file. Check logs for more info.")
}
return true
}
private fun mergeOverlay(original: ByteArray, overlay: ByteArray, isPreviewMode: Boolean): ByteArray? {
context.longToast("Merging current media with overlay. This may take a while.")
val originalFileType = FileType.fromByteArray(original)
val overlayFileType = FileType.fromByteArray(overlay)
//merge files
val mergedFile = File.createTempFile("merged", "." + originalFileType.fileExtension)
val tempVideoFile = File.createTempFile("original", "." + originalFileType.fileExtension).also {
with(FileOutputStream(it)) {
write(original)
close()
}
}
val tempOverlayFile = File.createTempFile("overlay", "." + overlayFileType.fileExtension).also {
with(FileOutputStream(it)) {
write(overlay)
close()
}
}
//TODO: improve ffmpeg speed
val fFmpegSession = FFmpegKit.execute(
"-y -i " +
tempVideoFile.absolutePath +
" -i " +
tempOverlayFile.absolutePath +
" -filter_complex \"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink; [img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\" -c:v libx264 -q:v 13 -c:a copy " +
" -threads 6 ${(if (isPreviewMode) "-frames:v 1" else "")} " +
mergedFile.absolutePath
)
tempVideoFile.delete()
tempOverlayFile.delete()
if (fFmpegSession.returnCode.value != 0) {
mergedFile.delete()
context.longToast("Failed to merge video and overlay. See logs for more details.")
Logger.xposedLog(fFmpegSession.output)
return null
}
val mergedFileData: ByteArray = FileInputStream(mergedFile).readBytes()
mergedFile.delete()
return mergedFileData
}
private fun queryMediaData(mediaInfo: MediaInfo): ByteArray {
val mediaUri = Uri.parse(mediaInfo.uri)
val mediaInputStream = AtomicReference<InputStream>()
if (mediaUri.scheme == "file") {
mediaInputStream.set(Paths.get(mediaUri.path).inputStream())
} else {
val url = URL(mediaUri.toString())
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("User-Agent", Constants.USER_AGENT)
connection.connect()
mediaInputStream.set(connection.inputStream)
}
mediaInfo.encryption?.let { encryption ->
mediaInputStream.set(CipherInputStream(mediaInputStream.get(), encryption.newCipher(Cipher.DECRYPT_MODE)))
}
return mediaInputStream.get().readBytes()
}
private fun createNeededDirectories(file: File): File {
val directory = file.parentFile ?: return file
if (!directory.exists()) {
directory.mkdirs()
}
return file
}
private fun isFileExists(hash: Int, author: String, fileType: FileType): Boolean {
val fileName: String = createNewFilePath(hash, author, fileType) ?: return false
val outputFile: File =
createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName))
return outputFile.exists()
}
/*
* Download the last seen media
*/
fun downloadLastOperaMediaAsync() {
if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return
context.executeAsync {
handleOperaMedia(
lastSeenMapParams!!,
lastSeenMediaInfoMap!!, true
)
}
}
private fun downloadOperaMedia(mediaInfoMap: Map<MediaType, MediaInfo>, author: String) {
if (mediaInfoMap.isEmpty()) return
val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!!
if (mediaInfoMap.containsKey(MediaType.OVERLAY)) {
context.shortToast("Downloading split snap")
}
var mediaContent: ByteArray? = queryMediaData(originalMediaInfo)
val hash = Arrays.hashCode(mediaContent)
if (mediaInfoMap.containsKey(MediaType.OVERLAY)) {
//prevent converting the same media twice
if (isFileExists(hash, author, FileType.fromByteArray(mediaContent!!))) {
context.shortToast("Media already exists")
return
}
val overlayMediaInfo = mediaInfoMap[MediaType.OVERLAY]!!
val overlayContent: ByteArray = queryMediaData(overlayMediaInfo)
mediaContent = mergeOverlay(mediaContent, overlayContent, false)
}
val fileType = FileType.fromByteArray(mediaContent!!)
downloadMediaContent(mediaContent, hash, author, fileType)
}
private fun downloadMediaContent(
data: ByteArray,
hash: Int,
messageAuthor: String,
fileType: FileType
): Boolean {
val fileName: String = createNewFilePath(hash, messageAuthor, fileType) ?: return false
val outputFile: File = createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName))
if (outputFile.exists()) {
context.shortToast("Media already exists")
return false
}
return downloadFile(outputFile, data)
}
/**
* Handles the media from the opera viewer
*
* @param paramMap the parameters from the opera viewer
* @param mediaInfoMap the media info map
* @param forceDownload if the media should be downloaded
*/
private fun handleOperaMedia(
paramMap: ParamMap,
mediaInfoMap: Map<MediaType, MediaInfo>,
forceDownload: Boolean
) {
//messages
if (paramMap.containsKey("MESSAGE_ID")) {
val id = paramMap["MESSAGE_ID"].toString()
val messageId = id.substring(id.lastIndexOf(":") + 1).toLong()
val senderId: String = context.database.getConversationMessageFromId(messageId)!!.sender_id!!
val author = context.database.getFriendInfo(senderId)!!.usernameForSorting!!
downloadOperaMedia(mediaInfoMap, author)
return
}
//private stories
val playlistV2Group =
if (paramMap.containsKey("PLAYLIST_V2_GROUP")) paramMap["PLAYLIST_V2_GROUP"].toString() else null
if (playlistV2Group != null &&
playlistV2Group.contains("storyUserId=") &&
(forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_STORIES))
) {
val storyIdStartIndex = playlistV2Group.indexOf("storyUserId=") + 12
val storyUserId = playlistV2Group.substring(storyIdStartIndex, playlistV2Group.indexOf(",", storyIdStartIndex))
val author = context.database.getFriendInfo(storyUserId)
downloadOperaMedia(mediaInfoMap, author!!.usernameForSorting!!)
return
}
val snapSource = paramMap["SNAP_SOURCE"].toString()
//public stories
if (snapSource == "PUBLIC_USER" && (forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_PUBLIC_STORIES))) {
val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace(
"[^\\x00-\\x7F]".toRegex(),
"")
downloadOperaMedia(mediaInfoMap, "Public-Stories/$userDisplayName")
}
//spotlight
if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_SPOTLIGHT))) {
downloadOperaMedia(mediaInfoMap, "Spotlight")
}
}
override fun asyncOnActivityCreate() {
val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "Class")
val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param ->
val viewState = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "viewStateField")).toString()
if (viewState != "FULLY_DISPLAYED") {
return@onOperaViewStateCallback
}
val operaLayerList = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "layerListField")) as ArrayList<*>
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list"))
return@onOperaViewStateCallback
val mediaInfoMap = mutableMapOf<MediaType, MediaInfo>()
val isVideo = mediaParamMap.containsKey("video_media_info_list")
mediaInfoMap[MediaType.ORIGINAL] = MediaInfo(
(if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!!
)
if (canMergeOverlay() && mediaParamMap.containsKey("overlay_image_media_info")) {
mediaInfoMap[MediaType.OVERLAY] =
MediaInfo(mediaParamMap["overlay_image_media_info"]!!)
}
lastSeenMapParams = mediaParamMap
lastSeenMediaInfoMap = mediaInfoMap
if (!context.config.bool(ConfigProperty.MEDIA_DOWNLOADER_FEATURE)) return@onOperaViewStateCallback
context.executeAsync {
try {
handleOperaMedia(mediaParamMap, mediaInfoMap, false)
} catch (e: Throwable) {
Logger.xposedLog(e)
context.longToast(e.message!!)
}
}
}
arrayOf("onDisplayStateChange", "onDisplayStateChange2").forEach { methodName ->
Hooker.hook(
operaViewerControllerClass,
context.mappings.getMappedValue("OperaPageViewController", methodName),
HookStage.AFTER, onOperaViewStateCallback
)
}
}
/**
* Called when a message is focused in chat
*/
//TODO: use snapchat classes instead of database (when content is deleted)
fun onMessageActionMenu(isPreviewMode: Boolean) {
//check if the message was focused in a conversation
val messaging = context.feature(Messaging::class)
if (messaging.lastOpenedConversationUUID == null) return
val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return
//get the message author
val messageAuthor: String = context.database.getFriendInfo(message.sender_id!!)!!.usernameForSorting!!
//check if the messageId
val contentType: ContentType = ContentType.fromId(message.content_type)
if (context.feature(MessageLogger::class).isMessageRemoved(message.client_message_id.toLong())) {
context.shortToast("Preview/Download are not yet available for deleted messages")
return
}
if (contentType != ContentType.NOTE &&
contentType != ContentType.SNAP &&
contentType != ContentType.EXTERNAL_MEDIA) {
context.shortToast("Unsupported content type $contentType")
return
}
val messageReader = ProtoReader(message.message_content!!)
val urlKey: String = messageReader.getString(*ARROYO_URL_KEY_PROTO_PATH)!!
//download the message content
try {
context.shortToast("Retriving message media")
var inputStream: InputStream = CdnDownloader.downloadWithDefaultEndpoints(urlKey) ?: return
inputStream = EncryptionUtils.decryptInputStreamFromArroyo(
inputStream,
contentType,
messageReader
)
var mediaData: ByteArray = inputStream.readBytes()
var fileType = FileType.fromByteArray(mediaData)
val isZipFile = fileType == FileType.ZIP
//videos with overlay are packed in a zip file
//there are 2 files in the zip file, the video (webm) and the overlay (png)
if (isZipFile) {
var videoData: ByteArray? = null
var overlayData: ByteArray? = null
val zipInputStream = ZipInputStream(ByteArrayInputStream(mediaData))
while (zipInputStream.nextEntry != null) {
val zipEntryData: ByteArray = zipInputStream.readBytes()
val entryFileType = FileType.fromByteArray(zipEntryData)
if (entryFileType.isVideo) {
videoData = zipEntryData
} else if (entryFileType.isImage) {
overlayData = zipEntryData
}
}
if (videoData == null || overlayData == null) {
Logger.xposedLog("Invalid data in zip file")
return
}
val mergedVideo = mergeOverlay(videoData, overlayData, isPreviewMode)
val videoFileType = FileType.fromByteArray(videoData)
if (!isPreviewMode) {
downloadMediaContent(
mergedVideo!!,
Arrays.hashCode(videoData),
messageAuthor,
videoFileType
)
return
}
mediaData = mergedVideo!!
fileType = videoFileType
}
if (isPreviewMode) {
runCatching {
val bitmap: Bitmap = PreviewUtils.createPreview(mediaData, fileType.isVideo)!!
val builder = AlertDialog.Builder(context.mainActivity)
builder.setTitle("Preview")
val imageView = ImageView(builder.context)
imageView.setImageBitmap(bitmap)
builder.setView(imageView)
builder.setPositiveButton(
"Close"
) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
context.runOnUiThread { builder.show() }
}.onFailure {
context.shortToast("Failed to create preview: ${it.message}")
xposedLog(it)
}
return
}
downloadMediaContent(mediaData, mediaData.contentHashCode(), messageAuthor, fileType)
} catch (e: FileNotFoundException) {
context.shortToast("Unable to get $urlKey from cdn list. Check the logs for more info")
} catch (e: Throwable) {
context.shortToast("Failed to download " + e.message)
xposedLog(e)
}
}
}

View File

@ -0,0 +1,133 @@
package me.rhunk.snapenhance.features.impl.extras
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.config.ConfigProperty
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.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.spy.MessageLogger
import me.rhunk.snapenhance.features.impl.spy.StealthMode
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.CallbackBuilder
import me.rhunk.snapenhance.util.getObjectField
import java.util.concurrent.Executors
class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
private val asyncSaveExecutorService = Executors.newSingleThreadExecutor()
private val messageLogger by lazy { context.feature(MessageLogger::class) }
private val messaging by lazy { context.feature(Messaging::class) }
private val myUserId by lazy { context.database.getMyUserId() }
private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") }
private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") }
private val updateMessageMethod by lazy { context.classCache.conversationManager.methods.first { it.name == "updateMessage" } }
private val fetchConversationWithMessagesPaginatedMethod by lazy {
context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" }
}
private fun saveMessage(conversationId: SnapUUID, message: Message) {
val messageId = message.messageDescriptor.messageId
if (messageLogger.isMessageRemoved(messageId)) return
val callback = CallbackBuilder(callbackClass)
.override("onError") {
Logger.xposedLog("Error saving message $messageId")
}.build()
runCatching {
updateMessageMethod.invoke(
context.feature(Messaging::class).conversationManager,
conversationId.instance(),
messageId,
context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == "SAVE" },
callback
)
}.onFailure {
Logger.xposedLog("Error saving message $messageId", it)
}
//delay between saves
Thread.sleep(100L)
}
private fun canSaveMessage(message: Message): Boolean {
if (message.messageMetadata.savedBy.any { uuid -> uuid.toString() == myUserId }) return false
//only save chats
with(message.messageContent.contentType) {
if (this != ContentType.CHAT &&
this != ContentType.NOTE &&
this != ContentType.STICKER &&
this != ContentType.EXTERNAL_MEDIA) return false
}
return true
}
private fun canSave(): Boolean {
with(context.feature(Messaging::class)) {
if (lastOpenedConversationUUID == null || context.feature(StealthMode::class).isStealth(lastOpenedConversationUUID.toString())) return@canSave false
}
return true
}
override fun asyncOnActivityCreate() {
//called when enter in a conversation (or when a message is sent)
Hooker.hook(
context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback"),
"onFetchConversationWithMessagesComplete",
HookStage.BEFORE,
{ context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()}
) { param ->
val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId"))
val messages = param.arg<List<Any>>(1).map { Message(it) }
messages.forEach {
if (!canSaveMessage(it)) return@forEach
asyncSaveExecutorService.submit {
saveMessage(conversationId, it)
}
}
}
//called when a message is received
Hooker.hook(
context.mappings.getMappedClass("callbacks", "FetchMessageCallback"),
"onFetchMessageComplete",
HookStage.BEFORE,
{ context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()}
) { param ->
val message = Message(param.arg(0))
if (!canSaveMessage(message)) return@hook
val conversationId = message.messageDescriptor.conversationId
asyncSaveExecutorService.submit {
saveMessage(conversationId, message)
}
}
Hooker.hook(
context.mappings.getMappedClass("callbacks", "SendMessageCallback"),
"onSuccess",
HookStage.BEFORE,
{ context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()}
) {
val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build()
runCatching {
fetchConversationWithMessagesPaginatedMethod.invoke(
messaging.conversationManager, messaging.lastOpenedConversationUUID!!.instance(),
Long.MAX_VALUE,
3,
callback
)
}.onFailure {
Logger.xposedLog("failed to save message", it)
}
}
}
}

View File

@ -0,0 +1,183 @@
package me.rhunk.snapenhance.features.impl.extras
import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.graphics.Bitmap
import android.os.Bundle
import android.os.UserHandle
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.MediaReferenceType
import me.rhunk.snapenhance.data.wrapper.impl.Message
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.CallbackBuilder
import me.rhunk.snapenhance.util.EncryptionUtils
import me.rhunk.snapenhance.util.PreviewUtils
import me.rhunk.snapenhance.util.download.CdnDownloader
import me.rhunk.snapenhance.util.protobuf.ProtoReader
class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) {
private val notificationDataQueue = mutableMapOf<Long, NotificationData>()
private val cachedNotifications = mutableMapOf<String, MutableList<String>>()
private val notifyAsUserMethod by lazy {
XposedHelpers.findMethodExact(
NotificationManager::class.java, "notifyAsUser",
String::class.java,
Int::class.javaPrimitiveType,
Notification::class.java,
UserHandle::class.java
)
}
private val fetchConversationWithMessagesMethod by lazy {
context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessages"}
}
private val notificationManager by lazy {
context.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
private fun setNotificationText(notification: NotificationData, text: String) {
with(notification.notification.extras) {
putString("android.text", text)
putString("android.bigText", text)
}
}
private fun computeNotificationText(conversationId: String): String {
val messageBuilder = StringBuilder()
cachedNotifications.computeIfAbsent(conversationId) { mutableListOf() }.forEach {
if (messageBuilder.isNotEmpty()) messageBuilder.append("\n")
messageBuilder.append(it)
}
return messageBuilder.toString()
}
private fun fetchMessagesResult(conversationId: String, messages: List<Message>) {
val sendNotificationData = { it: NotificationData ->
XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf(
it.tag, it.id, it.notification, it.userHandle
))
}
notificationDataQueue.entries.onEach { (messageId, notificationData) ->
val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return
val senderUsername = context.database.getFriendInfo(snapMessage.senderId.toString())?.displayName ?: throw Throwable("Cant find senderId of message $snapMessage")
val contentType = snapMessage.messageContent.contentType
val contentData = snapMessage.messageContent.content
val formatUsername: (String) -> String = { "$senderUsername: $it" }
val notificationCache = cachedNotifications.let { it.computeIfAbsent(conversationId) { mutableListOf() } }
val appendNotifications: () -> Unit = { setNotificationText(notificationData, computeNotificationText(conversationId))}
when (contentType) {
ContentType.NOTE -> {
notificationCache.add(formatUsername("sent audio note"))
appendNotifications()
}
ContentType.CHAT -> {
ProtoReader(contentData).getString(2, 1)?.trim()?.let {
notificationCache.add(formatUsername(it))
}
appendNotifications()
}
ContentType.SNAP -> {
//serialize the message content into a json object
val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instance()).asJsonObject
val mediaReferences = serializedMessageContent["mRemoteMediaReferences"]
.asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray }
.flatten()
mediaReferences.forEach { media ->
val mediaContent = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray()
val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString)
val urlKey = ProtoReader(mediaContent).getString(2, 2) ?: return@forEach
runCatching {
//download the media
var mediaInputStream = CdnDownloader.downloadWithDefaultEndpoints(urlKey)!!
val mediaInfo = ProtoReader(contentData).readPath(*Constants.MESSAGE_SNAP_ENCRYPTION_PROTO_PATH) ?: return@runCatching
//decrypt if necessary
if (mediaInfo.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) {
mediaInputStream = EncryptionUtils.decryptInputStream(mediaInputStream, false, mediaInfo, Constants.ARROYO_ENCRYPTION_PROTO_INDEX)
}
val mediaByteArray = mediaInputStream.readBytes()
val bitmapPreview = PreviewUtils.createPreview(mediaByteArray, mediaType == MediaReferenceType.VIDEO)!!
val notificationBuilder = XposedHelpers.newInstance(
Notification.Builder::class.java,
context.androidContext,
notificationData.notification
) as Notification.Builder
notificationBuilder.setLargeIcon(bitmapPreview)
notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?)
sendNotificationData(notificationData.copy(id = System.nanoTime().toInt(), notification = notificationBuilder.build()))
return@onEach
}.onFailure {
Logger.error("Failed to send preview notification", it)
}
}
}
else -> {
notificationCache.add(formatUsername("sent $contentType"))
}
}
sendNotificationData(notificationData)
}.clear()
}
override fun init() {
val fetchConversationWithMessagesCallback = context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback")
Hooker.hook(notifyAsUserMethod, HookStage.BEFORE, { context.config.bool(ConfigProperty.SHOW_MESSAGE_CONTENT) }) {
val notificationData = NotificationData(it.argNullable(0), it.arg(1), it.arg(2), it.arg(3))
if (!notificationData.notification.extras.containsKey("system_notification_extras")) {
return@hook
}
val extras: Bundle = notificationData.notification.extras.getBundle("system_notification_extras")!!
val messageId = extras.getString("message_id")!!
val notificationType = extras.getString("notification_type")!!
val conversationId = extras.getString("conversation_id")!!
if (!notificationType.endsWith("CHAT") && !notificationType.endsWith("SNAP")) return@hook
val conversationManager: Any = context.feature(Messaging::class).conversationManager
notificationDataQueue[messageId.toLong()] = notificationData
val callback = CallbackBuilder(fetchConversationWithMessagesCallback)
.override("onFetchConversationWithMessagesComplete") { param ->
val messageList = (param.arg(1) as List<Any>).map { msg -> Message(msg) }
fetchMessagesResult(conversationId, messageList)
}
.override("onError") { param ->
Logger.xposedLog("Failed to fetch message ${param.arg(0) as Any}")
}.build()
fetchConversationWithMessagesMethod.invoke(conversationManager, SnapUUID.fromString(conversationId).instance(), callback)
it.setResult(null)
}
}
data class NotificationData(
val tag: String?,
val id: Int,
var notification: Notification,
val userHandle: UserHandle
)
}

View File

@ -0,0 +1,28 @@
package me.rhunk.snapenhance.features.impl.extras
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
class SnapchatPlus: Feature("SnapchatPlus", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
if (!context.config.bool(ConfigProperty.SNAPCHAT_PLUS)) return
Hooker.hookConstructor(context.mappings.getMappedClass("SubscriptionInfoClass"), HookStage.BEFORE) { param ->
//check if the user is already premium
if (param.arg(0) as Int == 2) {
return@hookConstructor
}
//subscription info tier
param.setArg(0, 2)
//subscription status
param.setArg(1, 2)
//subscription time
param.setArg(2, System.currentTimeMillis() - 7776000000L)
//expiration time
param.setArg(3, System.currentTimeMillis() + 15552000000L)
}
}
}

View File

@ -0,0 +1,45 @@
package me.rhunk.snapenhance.features.impl.privacy
import de.robv.android.xposed.XposedHelpers
import me.rhunk.snapenhance.Logger.debug
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookAdapter
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import java.nio.charset.StandardCharsets
import java.util.*
class DisableMetrics : Feature("DisableMetrics", loadParams = FeatureLoadParams.INIT_SYNC) {
override fun init() {
val disableMetricsFilter: (HookAdapter) -> Boolean = {
context.config.bool(ConfigProperty.DISABLE_METRICS)
}
Hooker.hook(context.classCache.unifiedGrpcService, "unaryCall", HookStage.BEFORE, disableMetricsFilter) { param ->
val url: String = param.arg(0)
if (url.endsWith("snapchat.valis.Valis/SendClientUpdate") ||
url.endsWith("targetingQuery")
) {
param.setResult(null)
}
}
Hooker.hook(context.classCache.networkApi, "submit", HookStage.BEFORE, disableMetricsFilter) { param ->
val httpRequest: Any = param.arg(0)
val url = XposedHelpers.getObjectField(httpRequest, "mUrl").toString()
if (url.contains("resolve?co=")) {
val index = url.indexOf("co=")
val end = url.lastIndexOf("&")
val co = url.substring(index + 3, end)
val decoded = Base64.getDecoder().decode(co.toByteArray(StandardCharsets.UTF_8))
debug("decoded : " + decoded.toString(Charsets.UTF_8))
debug("content: $co")
}
if (url.contains("app-analytics") || url.endsWith("v1/metrics")) {
param.setResult(null)
}
}
}
}

View File

@ -0,0 +1,16 @@
package me.rhunk.snapenhance.features.impl.privacy
import android.database.ContentObserver
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
class PreventScreenshotDetections : Feature("Prevent Screenshot Detections", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
Hooker.hook(ContentObserver::class.java,"dispatchChange", HookStage.BEFORE, { context.config.bool(ConfigProperty.PREVENT_SCREENSHOTS) }) {
it.setResult(null)
}
}
}

View File

@ -0,0 +1,20 @@
package me.rhunk.snapenhance.features.impl.spy
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.getObjectField
class AnonymousStoryViewing : Feature("Anonymous Story Viewing", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
Hooker.hook(context.classCache.networkApi,"submit", HookStage.BEFORE, { context.config.bool(ConfigProperty.ANONYMOUS_STORY_VIEW) }) {
val httpRequest: Any = it.arg(0)
val url = httpRequest.getObjectField("mUrl") as String
if (url.endsWith("readreceipt-indexer/batchuploadreadreceipts") || url.endsWith("v2/batch_cta")) {
it.setResult(null)
}
}
}
}

View File

@ -0,0 +1,64 @@
package me.rhunk.snapenhance.features.impl.spy
import com.google.gson.JsonParser
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.data.MessageState
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.getObjectField
class MessageLogger : Feature("MessageLogger", loadParams = FeatureLoadParams.INIT_SYNC) {
private val messageCache = mutableMapOf<Long, String>()
private val removedMessages = linkedSetOf<Long>()
fun isMessageRemoved(messageId: Long) = removedMessages.contains(messageId)
override fun init() {
Hooker.hookConstructor(context.classCache.message, HookStage.AFTER, {
context.config.bool(ConfigProperty.MESSAGE_LOGGER)
}) {
val message = it.thisObject<Any>()
val messageId = message.getObjectField("mDescriptor").getObjectField("mMessageId") as Long
val contentType = ContentType.valueOf(message.getObjectField("mMessageContent").getObjectField("mContentType").toString())
val messageState = MessageState.valueOf(message.getObjectField("mState").toString())
if (messageState != MessageState.COMMITTED) return@hookConstructor
if (contentType == ContentType.STATUS) {
//query the deleted message
val deletedMessage: String = if (messageCache.containsKey(messageId)) messageCache[messageId] else {
context.bridgeClient.getMessageLoggerMessage(messageId)?.toString(Charsets.UTF_8)
} ?: return@hookConstructor
val messageJsonObject = JsonParser.parseString(deletedMessage).asJsonObject
//if the message is a snap make it playable
if (messageJsonObject["mMessageContent"].asJsonObject["mContentType"].asString == "SNAP") {
messageJsonObject["mMetadata"].asJsonObject.addProperty("mPlayableSnapState", "PLAYABLE")
}
//serialize all properties of messageJsonObject and put in the message object
message.javaClass.declaredFields.forEach { field ->
field.isAccessible = true
val fieldName = field.name
val fieldValue = messageJsonObject[fieldName]
if (fieldValue != null) {
field.set(message, context.gson.fromJson(fieldValue, field.type))
}
}
removedMessages.add(messageId)
return@hookConstructor
}
if (!messageCache.containsKey(messageId)) {
val serializedMessage = context.gson.toJson(message)
messageCache[messageId] = serializedMessage
context.bridgeClient.addMessageLoggerMessage(messageId, serializedMessage.toByteArray(Charsets.UTF_8))
}
}
}
}

View File

@ -0,0 +1,28 @@
package me.rhunk.snapenhance.features.impl.spy
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
class PreventReadReceipts : Feature("PreventReadReceipts", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
val isConversationInStealthMode: (SnapUUID) -> Boolean = hook@{
if (context.config.bool(ConfigProperty.PREVENT_READ_RECEIPTS)) return@hook true
context.feature(StealthMode::class).isStealth(it.toString())
}
arrayOf("mediaMessagesDisplayed", "displayedMessages").forEach { methodName: String ->
Hooker.hook(context.classCache.conversationManager, methodName, HookStage.BEFORE, { isConversationInStealthMode(SnapUUID(it.arg(0))) }) {
it.setResult(null)
}
}
Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) {
if (isConversationInStealthMode(SnapUUID(it.arg(1) as Any))) {
it.setResult(null)
}
}
}
}

View File

@ -0,0 +1,28 @@
package me.rhunk.snapenhance.features.impl.spy
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import me.rhunk.snapenhance.util.getObjectField
class PreventStatusNotifications : Feature("PreventStatusNotifications", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
override fun asyncOnActivityCreate() {
Hooker.hook(
context.classCache.conversationManager,
"sendMessageWithContent",
HookStage.BEFORE,
{context.config.bool(ConfigProperty.PREVENT_STATUS_NOTIFICATIONS) }) { param ->
val contentTypeString = (param.arg(1) as Any).getObjectField("mContentType")
if (contentTypeString == ContentType.STATUS_SAVE_TO_CAMERA_ROLL.name ||
contentTypeString == ContentType.STATUS_CONVERSATION_CAPTURE_SCREENSHOT.name ||
contentTypeString == ContentType.STATUS_CONVERSATION_CAPTURE_RECORD.name) {
param.setResult(null)
}
}
}
}

View File

@ -0,0 +1,62 @@
package me.rhunk.snapenhance.features.impl.spy
import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
class StealthMode : Feature("StealthMode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
private val stealthConversations = mutableListOf<String>()
override fun onActivityCreate() {
readStealthFile()
}
private fun writeStealthFile() {
val sb = StringBuilder()
for (stealthConversation in stealthConversations) {
sb.append(stealthConversation).append("\n")
}
context.bridgeClient.writeFile(
FileAccessRequest.FileType.STEALTH,
sb.toString().toByteArray(StandardCharsets.UTF_8)
)
}
private fun readStealthFile() {
val conversations = mutableListOf<String>()
val stealthFileData: ByteArray = context.bridgeClient.createAndReadFile(FileAccessRequest.FileType.STEALTH, ByteArray(0))
//read conversations
with(BufferedReader(InputStreamReader(
ByteArrayInputStream(stealthFileData),
StandardCharsets.UTF_8
))) {
var line: String = ""
while (readLine()?.also { line = it } != null) {
conversations.add(line)
}
close()
}
stealthConversations.clear()
stealthConversations.addAll(conversations)
}
fun setStealth(conversationId: String, stealth: Boolean) {
conversationId.hashCode().toLong().toString(16).let {
if (stealth) {
stealthConversations.add(it)
} else {
stealthConversations.remove(it)
}
}
writeStealthFile()
}
fun isStealth(conversationId: String): Boolean {
return stealthConversations.contains(conversationId.hashCode().toLong().toString(16))
}
}

View File

@ -0,0 +1,61 @@
package me.rhunk.snapenhance.features.impl.ui
import android.view.View
import android.view.ViewGroup
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
class UITweaks : Feature("UITweaks", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) {
override fun onActivityCreate() {
val resources = context.resources
val callButtonsStub = resources.getIdentifier("call_buttons_stub", "id", Constants.SNAPCHAT_PACKAGE_NAME)
val callButton1 = resources.getIdentifier("friend_action_button3", "id", Constants.SNAPCHAT_PACKAGE_NAME)
val callButton2 = resources.getIdentifier("friend_action_button4", "id", Constants.SNAPCHAT_PACKAGE_NAME)
val chatNoteRecordButton = resources.getIdentifier("chat_note_record_button", "id", Constants.SNAPCHAT_PACKAGE_NAME)
val chatInputBarSticker = resources.getIdentifier("chat_input_bar_sticker", "id", Constants.SNAPCHAT_PACKAGE_NAME)
val chatInputBarCognac = resources.getIdentifier("chat_input_bar_cognac", "id", Constants.SNAPCHAT_PACKAGE_NAME)
Hooker.hook(View::class.java, "setVisibility", HookStage.BEFORE) { methodParam ->
val viewId = (methodParam.thisObject() as View).id
if (viewId == chatNoteRecordButton && context.config.bool(ConfigProperty.REMOVE_VOICE_RECORD_BUTTON)) {
methodParam.setArg(0, View.GONE)
}
}
//TODO: use the event bus to dispatch a addView event
val addViewMethod = ViewGroup::class.java.getMethod(
"addView",
View::class.java,
Int::class.javaPrimitiveType,
ViewGroup.LayoutParams::class.java
)
Hooker.hook(addViewMethod, HookStage.BEFORE) { param ->
val view: View = param.arg(0)
val viewId = view.id
if (chatInputBarCognac == viewId && context.config.bool(ConfigProperty.REMOVE_COGNAC_BUTTON)) {
view.visibility = View.GONE
}
if (chatInputBarSticker == viewId && context.config.bool(ConfigProperty.REMOVE_STICKERS_BUTTON)) {
view.visibility = View.GONE
}
if (context.config.bool(ConfigProperty.REMOVE_CALLBUTTONS)) {
if (viewId == callButton1 || viewId == callButton2) {
if (view.visibility == View.GONE) return@hook
Hooker.ephemeralHookObjectMethod(View::class.java, view, "setVisibility", HookStage.BEFORE) { param ->
param.setArg(0, View.GONE)
}
}
if (viewId == callButtonsStub) {
param.setResult(null)
}
}
}
}
}

View File

@ -0,0 +1,9 @@
package me.rhunk.snapenhance.features.impl.ui.menus
import me.rhunk.snapenhance.ModContext
abstract class AbstractMenu() {
lateinit var context: ModContext
open fun init() {}
}

View File

@ -0,0 +1,139 @@
package me.rhunk.snapenhance.features.impl.ui.menus
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.LinearLayout
import de.robv.android.xposed.XposedBridge
import me.rhunk.snapenhance.Constants.VIEW_DRAWER
import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.ui.menus.impl.ChatActionMenu
import me.rhunk.snapenhance.features.impl.ui.menus.impl.FriendFeedInfoMenu
import me.rhunk.snapenhance.features.impl.ui.menus.impl.OperaContextActionMenu
import me.rhunk.snapenhance.features.impl.ui.menus.impl.SettingsMenu
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import java.lang.reflect.Field
import java.lang.reflect.Modifier
class MenuViewInjector : Feature("MenuViewInjector", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) {
private val friendFeedInfoMenu = FriendFeedInfoMenu()
private val operaContextActionMenu = OperaContextActionMenu()
private val chatActionMenu = ChatActionMenu()
private val settingMenu = SettingsMenu()
private fun wasInjectedView(view: View): Boolean {
if (view.getTag(VIEW_INJECTED_CODE) != null) return true
view.setTag(VIEW_INJECTED_CODE, true)
return false
}
@SuppressLint("ResourceType")
override fun asyncOnActivityCreate() {
friendFeedInfoMenu.context = context
operaContextActionMenu.context = context
chatActionMenu.context = context
settingMenu.context = context
val addViewMethod = ViewGroup::class.java.getMethod(
"addView",
View::class.java,
Int::class.javaPrimitiveType,
ViewGroup.LayoutParams::class.java
)
//catch the card view instance in the action drawer
Hooker.hook(
LinearLayout::class.java.getConstructor(
Context::class.java,
AttributeSet::class.java,
Int::class.javaPrimitiveType
), HookStage.AFTER
) { param ->
val viewGroup: LinearLayout = param.thisObject()
val attribute: Int = param.arg(2)
if (attribute == 0) return@hook
val resourceName = viewGroup.resources.getResourceName(attribute)
if (!resourceName.endsWith("snapCardContentLayoutStyle")) return@hook
viewGroup.setTag(VIEW_DRAWER, Any())
}
Hooker.hook(addViewMethod, HookStage.BEFORE) { param ->
val viewGroup: ViewGroup = param.thisObject()
val originalAddView: (View) -> Unit = { view: View ->
XposedBridge.invokeOriginalMethod(
addViewMethod,
viewGroup,
arrayOf(
view,
-1,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
)
}
val childView: View = param.arg(0)
operaContextActionMenu.inject(viewGroup, childView)
//download in chat snaps and notes from the chat action menu
if (viewGroup.javaClass.name.endsWith("ActionMenuChatItemContainer")) {
if (viewGroup.parent == null || viewGroup.parent
.parent == null
) return@hook
chatActionMenu.inject(viewGroup)
return@hook
}
//TODO : preview group chats
if (viewGroup !is LinearLayout) return@hook
if (viewGroup.getTag(VIEW_DRAWER) == null) return@hook
val itemStringInterface =childView.javaClass.declaredFields.filter { field: Field ->
!field.type.isPrimitive && Modifier.isAbstract(
field.type.modifiers
)
}
.map { field: Field ->
try {
field.isAccessible = true
return@map field[childView]
} catch (e: IllegalAccessException) {
e.printStackTrace()
}
null
}.firstOrNull()
//the 3 dot button shows a menu which contains the first item as a Plain object
//FIXME: better way to detect the 3 dot button
if (viewGroup.getChildCount() == 0 && itemStringInterface != null && itemStringInterface.toString().startsWith("Plain(primaryText=")) {
if (wasInjectedView(viewGroup)) return@hook
settingMenu.inject(viewGroup, originalAddView)
viewGroup.addOnAttachStateChangeListener(object: View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {}
override fun onViewDetachedFromWindow(v: View?) {
context.config.writeConfig()
}
})
return@hook
}
if (context.feature(Messaging::class).lastFetchConversationUserUUID == null) return@hook
//filter by the slot index
if (viewGroup.getChildCount() != context.config.int(ConfigProperty.MENU_SLOT_ID)) return@hook
friendFeedInfoMenu.inject(viewGroup, originalAddView)
childView.setTag(VIEW_DRAWER, null)
}
}
}

View File

@ -0,0 +1,53 @@
package me.rhunk.snapenhance.features.impl.ui.menus
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.view.Gravity
import android.view.View
import android.widget.Switch
import android.widget.TextView
object ViewAppearanceHelper {
fun applyIndentation(view: TextView) {
view.setPadding(70, 0, 55, 0)
}
@SuppressLint("UseSwitchCompatOrMaterialCode", "RtlHardcoded")
fun applyTheme(viewModel: View, view: TextView) {
//remove the shadow
view.setBackgroundColor(0x00000000)
view.setTextColor(Color.parseColor("#000000"))
view.setShadowLayer(0f, 0f, 0f, 0)
view.outlineProvider = null
view.gravity = Gravity.LEFT or Gravity.CENTER_VERTICAL
view.width = viewModel.width
//FIXME: hardcoded dimensions
view.height = 160
view.setPadding(35, 0, 55, 0)
view.isAllCaps = false
view.textSize = 15f
view.typeface = Typeface.DEFAULT
//remove click effect
if (view.javaClass == TextView::class.java) {
view.setBackgroundColor(0)
}
if (view is Switch) {
//set the switch color to blue
val colorStateList = ColorStateList(
arrayOf(
intArrayOf(-android.R.attr.state_checked), intArrayOf(
android.R.attr.state_checked
)
), intArrayOf(
Color.parseColor("#000000"),
Color.parseColor("#2196F3")
)
)
view.trackTintList = colorStateList
view.thumbTintList = colorStateList
}
}
}

View File

@ -0,0 +1,91 @@
package me.rhunk.snapenhance.features.impl.ui.menus.impl
import android.annotation.SuppressLint
import android.content.res.Resources
import android.graphics.BlendMode
import android.graphics.BlendModeColorFilter
import android.graphics.Color
import android.os.SystemClock
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.Button
import me.rhunk.snapenhance.Constants.VIEW_INJECTED_CODE
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
class ChatActionMenu : AbstractMenu() {
private fun wasInjectedView(view: View): Boolean {
if (view.getTag(VIEW_INJECTED_CODE) != null) return true
view.setTag(VIEW_INJECTED_CODE, true)
return false
}
private fun applyButtonTheme(parent: View, button: Button) {
button.background.colorFilter = BlendModeColorFilter(Color.WHITE, BlendMode.SRC_ATOP)
button.setTextColor(Color.BLACK)
button.transformationMethod = null
val margin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
20f,
Resources.getSystem().displayMetrics
).toInt()
val params = MarginLayoutParams(parent.layoutParams)
params.setMargins(margin, 5, margin, 5)
params.marginEnd = margin
params.marginStart = margin
button.layoutParams = params
button.height = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
50f,
Resources.getSystem().displayMetrics
).toInt()
}
@SuppressLint("SetTextI18n")
fun inject(viewGroup: ViewGroup) {
val parent = viewGroup.parent.parent as ViewGroup
if (wasInjectedView(parent)) return
//close the action menu using a touch event
val closeActionMenu = {
viewGroup.dispatchTouchEvent(
MotionEvent.obtain(
SystemClock.uptimeMillis(),
SystemClock.uptimeMillis(),
MotionEvent.ACTION_DOWN,
0f,
0f,
0
)
)
}
if (context.config.bool(ConfigProperty.DOWNLOAD_INCHAT_SNAPS)) {
val previewButton = Button(viewGroup.context)
applyButtonTheme(parent, previewButton)
previewButton.text = "Preview"
previewButton.setOnClickListener {
closeActionMenu()
context.executeAsync { context.feature(MediaDownloader::class).onMessageActionMenu(true) }
}
parent.addView(previewButton)
}
//download snap in chat
if (context.config.bool(ConfigProperty.DOWNLOAD_INCHAT_SNAPS)) {
val downloadButton = Button(viewGroup.context)
applyButtonTheme(parent, downloadButton)
downloadButton.text = "Download"
downloadButton.setOnClickListener {
closeActionMenu()
context.executeAsync { context.feature(MediaDownloader::class).onMessageActionMenu(false) }
}
parent.addView(downloadButton)
}
//TODO: delete logged message button
}
}

View File

@ -0,0 +1,231 @@
package me.rhunk.snapenhance.features.impl.ui.menus.impl
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.DialogInterface
import android.content.res.Resources
import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.view.View
import android.widget.Button
import android.widget.CompoundButton
import android.widget.Switch
import android.widget.Toast
import de.robv.android.xposed.XposedBridge
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.database.objects.ConversationMessage
import me.rhunk.snapenhance.database.objects.FriendInfo
import me.rhunk.snapenhance.database.objects.UserConversationLink
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.spy.StealthMode
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme
import java.net.HttpURLConnection
import java.net.URL
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
class FriendFeedInfoMenu : AbstractMenu() {
private fun getImageDrawable(url: String): Drawable {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connect()
val input = connection.inputStream
return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input))
}
private fun formatDate(timestamp: Long): String? {
return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp))
}
fun showProfileInfo(profile: FriendInfo) {
var icon: Drawable? = null
try {
if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) {
icon = getImageDrawable(
"https://sdk.bitmoji.com/render/panel/" + profile.bitmojiSelfieId
.toString() + "-" + profile.bitmojiAvatarId
.toString() + "-v1.webp?transparent=1&scale=0"
)
}
} catch (e: Throwable) {
Logger.xposedLog(e)
}
val finalIcon = icon
context.runOnUiThread {
val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp)
val builder = AlertDialog.Builder(context.mainActivity)
builder.setIcon(finalIcon)
builder.setTitle(profile.displayName)
val birthday = Calendar.getInstance()
birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1
val message: String = """
${context.translation.get("info.username")}: ${profile.username}
${context.translation.get("info.display_name")}: ${profile.displayName}
${context.translation.get("info.added_date")}: ${formatDate(addedTimestamp)}
${birthday.getDisplayName(
Calendar.MONTH,
Calendar.LONG,
context.translation.getLocale()
)?.let {
context.translation.get("info.birthday")
.replace("{month}", it)
.replace("{day}", profile.birthday.toInt().toString())
}
}
""".trimIndent()
builder.setMessage(message)
builder.setPositiveButton(
"OK"
) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
builder.show()
}
}
fun showPreview(userId: String?, conversationId: String, androidCtx: Context?) {
//query message
val messages: List<ConversationMessage>? = context.database.getMessagesFromConversationId(
conversationId,
context.config.int(ConfigProperty.MESSAGE_PREVIEW_LENGTH)
)?.reversed()
if (messages == null || messages.isEmpty()) {
Toast.makeText(androidCtx, "No messages found", Toast.LENGTH_SHORT).show()
return
}
val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!!
.map { context.database.getFriendInfo(it)!! }
.associateBy { it.userId!! }
val messageBuilder = StringBuilder()
messages.forEach{ message: ConversationMessage ->
val sender: FriendInfo? = participants[message.sender_id]
var messageString: String = message.getMessageAsString() ?: ContentType.fromId(message.content_type).name
if (message.content_type == ContentType.SNAP.id) {
val readTimeStamp: Long = message.read_timestamp
messageString = "\uD83D\uDFE5" //red square
if (readTimeStamp > 0) {
messageString += " \uD83D\uDC40 " //eyes
messageString += DateFormat.getDateTimeInstance(
DateFormat.SHORT,
DateFormat.SHORT
).format(Date(readTimeStamp))
}
}
var displayUsername = sender?.displayName ?: "Unknown user"
if (displayUsername.length > 12) {
displayUsername = displayUsername.substring(0, 13) + "... "
}
messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n")
}
val targetPerson: FriendInfo? =
if (userId == null) null else participants[userId]
targetPerson?.let {
val timeSecondDiff = ((it.streakExpirationTimestamp - System.currentTimeMillis()) / 1000 / 60).toInt()
messageBuilder.append("\n\n")
.append("\uD83D\uDD25 ") //fire emoji
.append(context.translation.get("streak_expiration").format(
timeSecondDiff / 60 / 24,
timeSecondDiff / 60 % 24,
timeSecondDiff % 60
))
}
//alert dialog
val builder = AlertDialog.Builder(context.mainActivity)
builder.setTitle(context.translation.get("preview"))
builder.setMessage(messageBuilder.toString())
builder.setPositiveButton(
"OK"
) { dialog: DialogInterface, _: Int -> dialog.dismiss() }
targetPerson?.let {
builder.setNegativeButton(context.translation.get("profile_info")) {_, _ ->
context.executeAsync {
showProfileInfo(it)
}
}
}
builder.show()
}
@SuppressLint("SetTextI18n", "UseSwitchCompatOrMaterialCode", "DefaultLocale")
fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) {
val messaging = context.feature(Messaging::class)
var focusedConversationTargetUser: String? = null
val conversationId: String
if (messaging.lastFetchConversationUserUUID != null) {
focusedConversationTargetUser = messaging.lastFetchConversationUserUUID.toString()
val conversation: UserConversationLink = context.database.getDMConversationIdFromUserId(focusedConversationTargetUser) ?: return
conversationId = conversation.client_conversation_id!!.trim().lowercase()
} else {
conversationId = messaging.lastFetchConversationUUID.toString()
}
//preview button
val previewButton = Button(viewModel.context)
previewButton.text = context.translation.get("preview")
applyTheme(viewModel, previewButton)
val finalFocusedConversationTargetUser = focusedConversationTargetUser
previewButton.setOnClickListener { v: View? ->
showPreview(
finalFocusedConversationTargetUser,
conversationId,
previewButton.context
)
}
//export conversation
/*val exportButton = Button(viewModel.context)
exportButton.setText(context.translation.get("conversation_export"))
applyTheme(viewModel, exportButton)
exportButton.setOnClickListener { event: View? ->
conversationExport.exportConversation(
SnapUUID(conversationId)
)
}*/
//stealth switch
val stealthSwitch = Switch(viewModel.context)
stealthSwitch.text = context.translation.get("stealth_mode")
stealthSwitch.isChecked = context.feature(StealthMode::class).isStealth(conversationId)
applyTheme(viewModel, stealthSwitch)
stealthSwitch.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean ->
context.feature(StealthMode::class).setStealth(
conversationId,
isChecked
)
}
/*//click to delete switch
val clickToDeleteSwitch = Switch(viewModel.context)
clickToDeleteSwitch.setText(context.translation.get("click_to_delete"))
clickToDeleteSwitch.isChecked = clickToDelete.isClickToDelete(conversationId)
applyTheme(viewModel, clickToDeleteSwitch)
clickToDeleteSwitch.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean ->
clickToDelete.setClickToDelete(
conversationId,
isChecked
)
}*/
/* if (configManager.getBoolean(ConfigCategory.EXTRAS, "conversation_export")
.isState()
) viewConsumer.accept(exportButton)
if (configManager.getBoolean(ConfigCategory.PRIVACY, "click_to_delete")
.isState()
) viewConsumer.accept(clickToDeleteSwitch)*/
viewConsumer(stealthSwitch)
viewConsumer(previewButton)
}
}

View File

@ -0,0 +1,82 @@
package me.rhunk.snapenhance.features.impl.ui.menus.impl
import android.annotation.SuppressLint
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import de.robv.android.xposed.XposedBridge
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme
class OperaContextActionMenu : AbstractMenu() {
private val contextCardsScrollView by lazy {
context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME)
}
/*
LinearLayout :
- LinearLayout:
- SnapFontTextView
- ImageView
- LinearLayout:
- SnapFontTextView
- ImageView
- LinearLayout:
- SnapFontTextView
- ImageView
*/
private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean {
if (viewGroup !is LinearLayout) return false
val children = ArrayList<View>()
for (i in 0 until viewGroup.getChildCount())
children.add(viewGroup.getChildAt(i))
return if (children.any { view: View? -> view !is LinearLayout })
false
else children.map { view: View -> view as LinearLayout }
.any { linearLayout: LinearLayout ->
val viewChildren = ArrayList<View>()
for (i in 0 until linearLayout.childCount) viewChildren.add(
linearLayout.getChildAt(
i
)
)
viewChildren.any { viewChild: View ->
viewChild.javaClass.name.endsWith("SnapFontTextView")
}
}
}
@SuppressLint("SetTextI18n")
fun inject(viewGroup: ViewGroup, childView: View) {
try {
if (viewGroup.parent !is ScrollView) return
val parent = viewGroup.parent as ScrollView
if (parent.id != contextCardsScrollView) return
if (childView !is LinearLayout) return
if (!isViewGroupButtonMenuContainer(childView as ViewGroup)) return
val linearLayout = LinearLayout(childView.getContext())
linearLayout.orientation = LinearLayout.VERTICAL
linearLayout.gravity = Gravity.CENTER
linearLayout.layoutParams =
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
val button = Button(childView.getContext())
button.text = context.translation.get("download_opera")
button.setOnClickListener { context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() }
applyTheme(linearLayout, button)
linearLayout.addView(button)
(childView as ViewGroup).addView(linearLayout, 0)
} catch (e: Throwable) {
Logger.xposedLog(e)
}
}
}

View File

@ -0,0 +1,133 @@
package me.rhunk.snapenhance.features.impl.ui.menus.impl
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.text.InputType
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.Switch
import android.widget.TextView
import me.rhunk.snapenhance.BuildConfig
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu
import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper
class SettingsMenu : AbstractMenu() {
private fun createCategoryTitle(viewModel: View, key: String): TextView {
val categoryText = TextView(viewModel.context)
categoryText.text = context.translation.get(key)
ViewAppearanceHelper.applyTheme(viewModel, categoryText)
categoryText.textSize = 18f
return categoryText
}
@SuppressLint("SetTextI18n")
private fun createPropertyView(viewModel: View, property: ConfigProperty): View {
val updateButtonText: (TextView, String) -> Unit = { textView, text ->
textView.text = "${context.translation.get(property.nameKey)} $text"
}
val textEditor: ((String) -> Unit) -> Unit = { updateValue ->
val builder = AlertDialog.Builder(viewModel.context)
builder.setTitle(context.translation.get(property.nameKey))
val input = EditText(viewModel.context)
input.inputType = InputType.TYPE_CLASS_TEXT
input.setText(context.config.string(property))
builder.setView(input)
builder.setPositiveButton("OK") { _, _ ->
updateValue(input.text.toString())
}
builder.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() }
builder.show()
}
val resultView: View = when (property.defaultValue) {
is String -> {
val textView = TextView(viewModel.context)
updateButtonText(textView, context.config.string(property))
ViewAppearanceHelper.applyTheme(viewModel, textView)
textView.setOnClickListener {
textEditor { value ->
context.config.set(property, value)
updateButtonText(textView, value)
}
}
textView
}
is Number -> {
val button = Button(viewModel.context)
updateButtonText(button, context.config.get(property).toString())
button.setOnClickListener {
textEditor { value ->
runCatching {
context.config.set(property, when (property.defaultValue) {
is Int -> value.toInt()
is Double -> value.toDouble()
is Float -> value.toFloat()
is Long -> value.toLong()
is Short -> value.toShort()
is Byte -> value.toByte()
else -> throw IllegalArgumentException()
})
updateButtonText(button, value)
}.onFailure {
context.shortToast("Invalid value")
}
}
}
ViewAppearanceHelper.applyTheme(viewModel, button)
button
}
is Boolean -> {
val switch = Switch(viewModel.context)
switch.text = context.translation.get(property.nameKey)
switch.isChecked = context.config.bool(property)
switch.setOnCheckedChangeListener { _, isChecked ->
context.config.set(property, isChecked)
}
ViewAppearanceHelper.applyTheme(viewModel, switch)
switch
}
else -> {
TextView(viewModel.context)
}
}
return resultView
}
@SuppressLint("SetTextI18n")
fun inject(viewModel: View, addView: (View) -> Unit) {
val packageInfo = viewModel.context.packageManager.getPackageInfo(
Constants.SNAPCHAT_PACKAGE_NAME,
0
)
val versionTextBuilder = StringBuilder()
versionTextBuilder.append("SnapEnhance ").append(BuildConfig.VERSION_NAME)
.append(" by rhunk")
if (BuildConfig.DEBUG) {
versionTextBuilder.append("\n").append("Snapchat ").append(packageInfo.versionName)
.append(" (").append(packageInfo.longVersionCode).append(")")
}
val titleText = TextView(viewModel.context)
titleText.text = versionTextBuilder.toString()
ViewAppearanceHelper.applyTheme(viewModel, titleText)
titleText.textSize = 18f
titleText.minHeight = 80 * versionTextBuilder.chars().filter { ch: Int -> ch == '\n'.code }
.count().coerceAtLeast(2).toInt()
addView(titleText)
context.config.entries().groupBy {
it.key.category
}.forEach { (category, value) ->
addView(createCategoryTitle(viewModel, category.key))
value.forEach {
addView(createPropertyView(viewModel, it.key))
}
}
}
}

View File

@ -0,0 +1,72 @@
package me.rhunk.snapenhance.hook
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import java.lang.reflect.Member
import java.util.function.Consumer
@Suppress("UNCHECKED_CAST")
class HookAdapter(
private val methodHookParam: XC_MethodHook.MethodHookParam<*>
) {
fun <T : Any> thisObject(): T {
return methodHookParam.thisObject as T
}
fun method(): Member {
return methodHookParam.method
}
fun <T : Any> arg(index: Int): T {
return methodHookParam.args[index] as T
}
fun <T : Any> argNullable(index: Int): T? {
return methodHookParam.args[index] as T?
}
fun setArg(index: Int, value: Any?) {
if (index < 0 || index >= methodHookParam.args.size) return
methodHookParam.args[index] = value
}
fun args(): Array<Any> {
return methodHookParam.args
}
fun getResult(): Any? {
return methodHookParam.result
}
fun setResult(result: Any?) {
methodHookParam.result = result
}
fun setThrowable(throwable: Throwable) {
methodHookParam.throwable = throwable
}
fun throwable(): Throwable? {
return methodHookParam.throwable
}
fun invokeOriginal(): Any? {
return XposedBridge.invokeOriginalMethod(method(), thisObject(), args())
}
fun invokeOriginal(args: Array<Any>): Any? {
return XposedBridge.invokeOriginalMethod(method(), thisObject(), args)
}
fun invokeOriginalSafe(errorCallback: Consumer<Throwable>) {
invokeOriginalSafe(args(), errorCallback)
}
fun invokeOriginalSafe(args: Array<Any>, errorCallback: Consumer<Throwable>) {
runCatching {
setResult(XposedBridge.invokeOriginalMethod(method(), thisObject(), args))
}.onFailure {
errorCallback.accept(it)
}
}
}

View File

@ -0,0 +1,6 @@
package me.rhunk.snapenhance.hook
enum class HookStage {
BEFORE,
AFTER
}

View File

@ -0,0 +1,94 @@
package me.rhunk.snapenhance.hook
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import java.lang.reflect.Member
object Hooker {
private fun newMethodHook(
stage: HookStage,
consumer: (HookAdapter) -> Unit,
filter: ((HookAdapter) -> Boolean) = { true }
) = object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam<*>) {
if (stage != HookStage.BEFORE) return
with(HookAdapter(param)) {
if (!filter(this)) return
consumer(this)
}
}
override fun afterHookedMethod(param: MethodHookParam<*>) {
if (stage != HookStage.AFTER) return
with(HookAdapter(param)) {
if (!filter(this)) return
consumer(this)
}
}
}
fun hook(
clazz: Class<*>,
methodName: String,
stage: HookStage,
consumer: (HookAdapter) -> Unit
): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer))
fun hook(
clazz: Class<*>,
methodName: String,
stage: HookStage,
filter: (HookAdapter) -> Boolean,
consumer: (HookAdapter) -> Unit
): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter))
fun hook(
member: Member,
stage: HookStage,
consumer: (HookAdapter) -> Unit
): XC_MethodHook.Unhook {
return XposedBridge.hookMethod(member, newMethodHook(stage, consumer))
}
fun hook(
member: Member,
stage: HookStage,
filter: ((HookAdapter) -> Boolean),
consumer: (HookAdapter) -> Unit
): XC_MethodHook.Unhook {
return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter))
}
fun hookConstructor(
clazz: Class<*>,
stage: HookStage,
consumer: (HookAdapter) -> Unit
) {
XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer))
}
fun hookConstructor(
clazz: Class<*>,
stage: HookStage,
filter: ((HookAdapter) -> Boolean),
consumer: (HookAdapter) -> Unit
) {
XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter))
}
fun ephemeralHookObjectMethod(
clazz: Class<*>,
instance: Any,
methodName: String,
stage: HookStage,
hookConsumer: (HookAdapter) -> Unit
) {
val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet()
hook(clazz, methodName, stage) { param->
if (param.thisObject<Any>() != instance) return@hook
hookConsumer(param)
unhooks.forEach{ it.unhook() }
}.also { unhooks.addAll(it) }
}
}

View File

@ -0,0 +1,6 @@
package me.rhunk.snapenhance.manager
interface Manager {
fun init() {}
fun onActivityCreate() {}
}

View File

@ -0,0 +1,63 @@
package me.rhunk.snapenhance.manager.impl
import com.google.gson.JsonObject
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest
import me.rhunk.snapenhance.config.ConfigAccessor
import me.rhunk.snapenhance.config.ConfigProperty
import me.rhunk.snapenhance.manager.Manager
import java.nio.charset.StandardCharsets
class ConfigManager(
private val context: ModContext,
config: MutableMap<ConfigProperty, Any?> = mutableMapOf()
) : ConfigAccessor(config), Manager {
private val propertyList = ConfigProperty.sortedByCategory()
override fun init() {
//generate default config
propertyList.forEach { key ->
set(key, key.defaultValue)
}
if (!context.bridgeClient.isFileExists(FileAccessRequest.FileType.CONFIG)) {
writeConfig()
return
}
runCatching {
loadConfig()
}.onFailure {
Logger.xposedLog("Failed to load config", it)
writeConfig()
}
}
private fun loadConfig() {
val configContent = context.bridgeClient.createAndReadFile(
FileAccessRequest.FileType.CONFIG,
"{}".toByteArray(Charsets.UTF_8)
)
val configObject: JsonObject = context.gson.fromJson(
String(configContent, StandardCharsets.UTF_8),
JsonObject::class.java
)
propertyList.forEach { key ->
val value = context.gson.fromJson(configObject.get(key.name), key.defaultValue.javaClass) ?: key.defaultValue
set(key, value)
}
}
fun writeConfig() {
val configObject = JsonObject()
propertyList.forEach { key ->
configObject.add(key.name, context.gson.toJsonTree(get(key)))
}
context.bridgeClient.writeFile(
FileAccessRequest.FileType.CONFIG,
context.gson.toJson(configObject).toByteArray(Charsets.UTF_8)
)
}
}

View File

@ -0,0 +1,93 @@
package me.rhunk.snapenhance.manager.impl
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.features.Feature
import me.rhunk.snapenhance.features.FeatureLoadParams
import me.rhunk.snapenhance.features.impl.ConfigEnumKeys
import me.rhunk.snapenhance.features.impl.Messaging
import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader
import me.rhunk.snapenhance.features.impl.extras.AutoSave
import me.rhunk.snapenhance.features.impl.extras.Notifications
import me.rhunk.snapenhance.features.impl.extras.SnapchatPlus
import me.rhunk.snapenhance.features.impl.privacy.DisableMetrics
import me.rhunk.snapenhance.features.impl.privacy.PreventScreenshotDetections
import me.rhunk.snapenhance.features.impl.spy.*
import me.rhunk.snapenhance.features.impl.ui.UITweaks
import me.rhunk.snapenhance.features.impl.ui.menus.MenuViewInjector
import me.rhunk.snapenhance.manager.Manager
import java.util.concurrent.Executors
import kotlin.reflect.KClass
class FeatureManager(private val context: ModContext) : Manager {
private val asyncLoadExecutorService = Executors.newCachedThreadPool()
private val features = mutableListOf<Feature>()
private fun register(featureClass: KClass<out Feature>) {
runCatching {
with(featureClass.java.newInstance()) {
if (loadParams and FeatureLoadParams.NO_INIT != 0) return@with
context = this@FeatureManager.context
features.add(this)
}
}.onFailure {
Logger.xposedLog("Failed to register feature ${featureClass.simpleName}", it)
}
}
@Suppress("UNCHECKED_CAST")
fun <T : Feature> get(featureClass: KClass<T>): T? {
return features.find { it::class == featureClass } as? T
}
override fun init() {
register(Messaging::class)
register(MediaDownloader::class)
register(StealthMode::class)
register(MenuViewInjector::class)
register(PreventReadReceipts::class)
register(AnonymousStoryViewing::class)
register(MessageLogger::class)
register(SnapchatPlus::class)
register(DisableMetrics::class)
register(PreventScreenshotDetections::class)
register(PreventStatusNotifications::class)
register(Notifications::class)
register(AutoSave::class)
register(UITweaks::class)
register(ConfigEnumKeys::class)
initializeFeatures()
}
private fun featureInitializer(isAsync: Boolean, param: Int, action: (Feature) -> Unit) {
features.forEach { feature ->
if (feature.loadParams and param == 0) return@forEach
val callback = {
runCatching {
action(feature)
}.onFailure {
Logger.xposedLog("Failed to init feature ${feature.nameKey}", it)
}
}
if (!isAsync) {
callback()
return@forEach
}
asyncLoadExecutorService.submit {
callback()
}
}
}
private fun initializeFeatures() {
//TODO: async called when all features are initiated ?
featureInitializer(false, FeatureLoadParams.INIT_SYNC) { it.init() }
featureInitializer(true, FeatureLoadParams.INIT_ASYNC) { it.asyncInit() }
}
override fun onActivityCreate() {
featureInitializer(false, FeatureLoadParams.ACTIVITY_CREATE_SYNC) { it.onActivityCreate() }
featureInitializer(true, FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { it.asyncOnActivityCreate() }
}
}

View File

@ -0,0 +1,178 @@
package me.rhunk.snapenhance.manager.impl
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import dalvik.system.DexFile
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest
import me.rhunk.snapenhance.manager.Manager
import me.rhunk.snapenhance.mapping.Mapper
import me.rhunk.snapenhance.mapping.impl.CallbackMapper
import me.rhunk.snapenhance.mapping.impl.EnumMapper
import me.rhunk.snapenhance.mapping.impl.OperaPageViewControllerMapper
import me.rhunk.snapenhance.mapping.impl.PlusSubscriptionMapper
import me.rhunk.snapenhance.util.getObjectField
import java.io.FileNotFoundException
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentHashMap
@Suppress("UNCHECKED_CAST")
class MappingManager(private val context: ModContext) : Manager {
private val mappers = mutableListOf<Mapper>().apply {
add(CallbackMapper())
add(EnumMapper())
add(OperaPageViewControllerMapper())
add(PlusSubscriptionMapper())
}
private val mappings = ConcurrentHashMap<String, Any>()
private var snapBuildNumber = 0
override fun init() {
val currentBuildNumber = context.androidContext.packageManager.getPackageInfo(
Constants.SNAPCHAT_PACKAGE_NAME,
0
).longVersionCode.toInt()
snapBuildNumber = currentBuildNumber
if (context.bridgeClient.isFileExists(FileAccessRequest.FileType.MAPPINGS)) {
runCatching {
loadCached()
}.onFailure {
if (it is FileNotFoundException) {
Logger.xposedLog(it)
context.forceCloseApp()
}
Logger.error("Failed to load cached mappings", it)
}
return
}
refresh()
}
private fun loadCached() {
if (!context.bridgeClient.isFileExists(FileAccessRequest.FileType.MAPPINGS)) {
Logger.xposedLog("Mappings file does not exist")
return
}
val mappingsObject = JsonParser.parseString(
String(
context.bridgeClient.readFile(FileAccessRequest.FileType.MAPPINGS),
StandardCharsets.UTF_8
)
).asJsonObject.also {
snapBuildNumber = it["snap_build_number"].asInt
}
mappingsObject.entrySet().forEach { (key, value): Map.Entry<String, JsonElement> ->
if (value.isJsonArray) {
mappings[key] = context.gson.fromJson(value, ArrayList::class.java)
return@forEach
}
if (value.isJsonObject) {
mappings[key] = context.gson.fromJson(value, ConcurrentHashMap::class.java)
return@forEach
}
mappings[key] = value.asString
}
}
private fun executeMappers(classes: List<Class<*>>) = runBlocking {
val jobs = mutableListOf<Job>()
mappers.forEach { mapper ->
mapper.context = context
launch {
runCatching {
mapper.useClasses(context.androidContext.classLoader, classes, mappings)
}.onFailure {
Logger.error("Failed to execute mapper ${mapper.javaClass.simpleName}", it)
}
}.also { jobs.add(it) }
}
jobs.forEach { it.join() }
}
@Suppress("UNCHECKED_CAST", "DEPRECATION")
private fun refresh() {
context.shortToast("Loading mappings (this may take a while)")
val classes: MutableList<Class<*>> = ArrayList()
val classLoader = context.androidContext.classLoader
val dexPathList = classLoader.getObjectField("pathList")
val dexElements = dexPathList.getObjectField("dexElements") as Array<Any>
dexElements.forEach { dexElement: Any ->
val dexFile = dexElement.getObjectField("dexFile") as DexFile
dexFile.entries().toList().forEach fileList@{ className ->
//ignore classes without a dot in them
if (className.contains(".") && !className.startsWith("com.snap")) return@fileList
runCatching {
classLoader.loadClass(className)?.let { classes.add(it) }
}
}
}
executeMappers(classes)
write()
}
private fun write() {
val mappingsObject = JsonObject()
mappingsObject.addProperty("snap_build_number", snapBuildNumber)
mappings.forEach { (key, value) ->
if (value is List<*>) {
mappingsObject.add(key, context.gson.toJsonTree(value))
return@forEach
}
if (value is Map<*, *>) {
mappingsObject.add(key, context.gson.toJsonTree(value))
return@forEach
}
mappingsObject.addProperty(key, value.toString())
}
context.bridgeClient.writeFile(
FileAccessRequest.FileType.MAPPINGS,
mappingsObject.toString().toByteArray()
)
}
fun getMappedObject(key: String): Any {
if (mappings.containsKey(key)) {
return mappings[key]!!
}
Logger.xposedLog("Mapping not found deleting cache")
context.bridgeClient.deleteFile(FileAccessRequest.FileType.MAPPINGS)
throw Exception("No mapping found for $key")
}
fun getMappedClass(className: String): Class<*> {
return context.androidContext.classLoader.loadClass(getMappedObject(className) as String)
}
fun getMappedClass(key: String, subKey: String): Class<*> {
return context.androidContext.classLoader.loadClass(getMappedValue(key, subKey))
}
fun getMappedValue(key: String): String {
return getMappedObject(key) as String
}
fun <T : Any> getMappedList(key: String): List<T> {
return listOf(getMappedObject(key) as List<T>).flatten()
}
fun getMappedValue(key: String, subKey: String): String {
return getMappedMap(key)[subKey] as String
}
fun getMappedMap(key: String): Map<String, *> {
return getMappedObject(key) as Map<String, *>
}
}

View File

@ -0,0 +1,19 @@
package me.rhunk.snapenhance.manager.impl
import me.rhunk.snapenhance.ModContext
import me.rhunk.snapenhance.manager.Manager
import java.util.*
class TranslationManager(
private val context: ModContext
) : Manager {
override fun init() {
}
fun getLocale(): Locale = Locale.getDefault()
fun get(key: String): String {
return key
}
}

View File

@ -0,0 +1,13 @@
package me.rhunk.snapenhance.mapping
import me.rhunk.snapenhance.ModContext
abstract class Mapper {
lateinit var context: ModContext
abstract fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
)
}

View File

@ -0,0 +1,29 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.Logger.debug
import me.rhunk.snapenhance.mapping.Mapper
import java.lang.reflect.Method
import java.lang.reflect.Modifier
class CallbackMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
val callbackMappings = HashMap<String, String>()
classes.forEach { clazz ->
val superClass = clazz.superclass ?: return@forEach
if (!superClass.name.endsWith("Callback") || superClass.name.endsWith("\$Callback")) return@forEach
if (!Modifier.isAbstract(superClass.modifiers)) return@forEach
if (superClass.declaredMethods.any { method: Method ->
method.name == "onError"
}) {
callbackMappings[superClass.simpleName] = clazz.name
}
}
debug("found " + callbackMappings.size + " callbacks")
mappings["callbacks"] = callbackMappings
}
}

View File

@ -0,0 +1,41 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.Logger.debug
import me.rhunk.snapenhance.mapping.Mapper
import java.lang.reflect.Method
import java.util.*
class EnumMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
val enumMappings = HashMap<String, String>()
//settings classes have an interface that extends Serializable and contains the getName method
//this enum classes are used to store the settings values
//Setting enum class -> implements an interface -> getName method
classes.forEach { clazz ->
if (!clazz.isEnum) return@forEach
if (clazz.interfaces.isEmpty()) return@forEach
val serializableInterfaceClass = clazz.interfaces[0]
if (serializableInterfaceClass.methods
.filter { method: Method -> method.declaringClass == serializableInterfaceClass }
.none { method: Method -> method.name == "getName" }
) return@forEach
runCatching {
val getEnumNameMethod =
serializableInterfaceClass.methods.first { it!!.returnType.isEnum }
clazz.enumConstants?.onEach { enumConstant ->
val enumName =
Objects.requireNonNull(getEnumNameMethod.invoke(enumConstant)).toString()
enumMappings[enumName] = clazz.name
}
}
}
debug("found " + enumMappings.size + " enums")
mappings["enums"] = enumMappings
}
}

View File

@ -0,0 +1,77 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.mapping.Mapper
import me.rhunk.snapenhance.util.ReflectionHelper
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import java.util.*
class OperaPageViewControllerMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
var operaPageViewControllerClass: Class<*>? = null
for (aClass in classes) {
if (!Modifier.isAbstract(aClass.modifiers)) continue
if (aClass.interfaces.isEmpty()) continue
val foundFields = Arrays.stream(aClass.declaredFields).filter { field: Field ->
val modifiers = field.modifiers
Modifier.isStatic(modifiers) && Modifier.isFinal(
modifiers
)
}.filter { field: Field ->
try {
return@filter "ad_product_type" == String.format("%s", field[null])
} catch (e: IllegalAccessException) {
e.printStackTrace()
}
false
}.count()
if (foundFields == 0L) continue
operaPageViewControllerClass = aClass
break
}
if (operaPageViewControllerClass == null) throw RuntimeException("OperaPageViewController not found")
val members = HashMap<String, String>()
members["Class"] = operaPageViewControllerClass.name
operaPageViewControllerClass.fields.forEach { field ->
val fieldType = field.type
if (fieldType.isEnum) {
fieldType.enumConstants.firstOrNull { enumConstant: Any -> enumConstant.toString() == "FULLY_DISPLAYED" }
.let { members["viewStateField"] = field.name }
}
if (fieldType == ArrayList::class.java) {
members["layerListField"] = field.name
}
}
val enumViewStateClass = operaPageViewControllerClass.fields.first { field: Field ->
field.name == members["viewStateField"]
}.type
//find the method that call the onDisplayStateChange method
members["onDisplayStateChange"] =
operaPageViewControllerClass.methods.first { method: Method ->
if (method.returnType != Void.TYPE || method.parameterTypes.size != 1) return@first false
val firstParameterClass = method.parameterTypes[0]
//check if the class contains a field with the enumViewStateClass type
ReflectionHelper.searchFieldByType(firstParameterClass, enumViewStateClass) != null
}.name
//find the method that call the onDisplayStateChange method from gestures
members["onDisplayStateChange2"] =
operaPageViewControllerClass.methods.first { method: Method ->
if (method.returnType != Void.TYPE || method.parameterTypes.size != 2) return@first false
val firstParameterClass = method.parameterTypes[0]
val secondParameterClass = method.parameterTypes[1]
firstParameterClass.isEnum && secondParameterClass.isEnum
}.name
mappings["OperaPageViewController"] = members
}
}

View File

@ -0,0 +1,32 @@
package me.rhunk.snapenhance.mapping.impl
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.mapping.Mapper
import java.lang.reflect.Field
import java.lang.reflect.Method
class PlusSubscriptionMapper : Mapper() {
override fun useClasses(
classLoader: ClassLoader,
classes: List<Class<*>>,
mappings: MutableMap<String, Any>
) {
//find a method that contains annotations with isSubscribed
val loadSubscriptionMethod = context.classCache.composerLocalSubscriptionStore.declaredMethods.first { method: Method ->
val returnType = method.returnType
returnType.declaredFields.any { field: Field ->
field.declaredAnnotations.any { annotation: Annotation ->
annotation.toString().contains("isSubscribed")
}
}
}
//get the first param of the method which is the PlusSubscriptionState class
val plusSubscriptionStateClass = loadSubscriptionMethod.parameterTypes[0]
//get the first param of the constructor of PlusSubscriptionState which is the SubscriptionInfo class
val subscriptionInfoClass = plusSubscriptionStateClass.constructors[0].parameterTypes[0]
Logger.debug("subscriptionInfoClass ${subscriptionInfoClass.name}")
mappings["SubscriptionInfoClass"] = subscriptionInfoClass.name
}
}

View File

@ -0,0 +1,93 @@
package me.rhunk.snapenhance.util
import de.robv.android.xposed.XC_MethodHook
import me.rhunk.snapenhance.hook.HookAdapter
import me.rhunk.snapenhance.hook.HookStage
import me.rhunk.snapenhance.hook.Hooker
import java.lang.reflect.Constructor
import java.lang.reflect.Field
import java.lang.reflect.Modifier
class CallbackBuilder(
private val callbackClass: Class<*>
) {
internal class Override(
val methodName: String,
val callback: (HookAdapter) -> Unit
)
private val methodOverrides = mutableListOf<Override>()
fun override(methodName: String, callback: (HookAdapter) -> Unit = {}): CallbackBuilder {
methodOverrides.add(Override(methodName, callback))
return this
}
fun build(): Any {
//get the first param of the first constructor to get the class of the invoker
val invokerClass: Class<*> = callbackClass.constructors[0].parameterTypes[0]
//get the invoker field based on the invoker class
val invokerField = callbackClass.fields.first { field: Field ->
field.type.isAssignableFrom(invokerClass)
}
//get the callback field based on the callback class
val callbackInstance = createEmptyObject(callbackClass.constructors[0])!!
val callbackInstanceHashCode: Int = callbackInstance.hashCode()
val callbackInstanceClass = callbackInstance.javaClass
val unhooks = mutableListOf<XC_MethodHook.Unhook>()
callbackInstanceClass.methods.forEach { method ->
if (method.declaringClass != callbackInstanceClass) return@forEach
if (Modifier.isPrivate(method.modifiers)) return@forEach
//default hook that unhooks the callback and returns null
val defaultHook: (HookAdapter) -> Boolean = defaultHook@{
//checking invokerField ensure that's the callback was created by the CallbackBuilder
if (invokerField.get(it.thisObject()) != null) return@defaultHook false
if ((it.thisObject() as Any).hashCode() != callbackInstanceHashCode) return@defaultHook false
it.setResult(null)
unhooks.forEach { unhook -> unhook.unhook() }
true
}
var hook: (HookAdapter) -> Unit = { defaultHook(it) }
//override the default hook if the method is in the override list
methodOverrides.find { it.methodName == method.name }?.run {
hook = {
if (defaultHook(it)) {
callback(it)
}
}
}
unhooks.add(Hooker.hook(method, HookStage.BEFORE, hook))
}
return callbackInstance
}
companion object {
private fun createEmptyObject(constructor: Constructor<*>): Any? {
//compute the args for the constructor with null or default primitive values
val args = constructor.parameterTypes.map { type: Class<*> ->
if (type.isPrimitive) {
when (type.name) {
"boolean" -> return@map false
"byte" -> return@map 0.toByte()
"char" -> return@map 0.toChar()
"short" -> return@map 0.toShort()
"int" -> return@map 0
"long" -> return@map 0L
"float" -> return@map 0f
"double" -> return@map 0.0
}
}
null
}.toTypedArray()
return constructor.newInstance(*args)
}
}
}

View File

@ -0,0 +1,67 @@
package me.rhunk.snapenhance.util
import me.rhunk.snapenhance.Constants
import me.rhunk.snapenhance.data.ContentType
import me.rhunk.snapenhance.util.protobuf.ProtoReader
import java.io.InputStream
import java.util.*
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object EncryptionUtils {
fun decryptInputStreamFromArroyo(
inputStream: InputStream,
contentType: ContentType,
messageProto: ProtoReader
): InputStream {
var resultInputStream = inputStream
val encryptionProtoPath: IntArray = when (contentType) {
ContentType.NOTE -> Constants.ARROYO_NOTE_ENCRYPTION_PROTO_PATH
ContentType.SNAP -> Constants.ARROYO_SNAP_ENCRYPTION_PROTO_PATH
ContentType.EXTERNAL_MEDIA -> Constants.ARROYO_EXTERNAL_MEDIA_ENCRYPTION_PROTO_PATH
else -> throw IllegalArgumentException("Invalid content type: $contentType")
}
//decrypt the content if needed
messageProto.readPath(*encryptionProtoPath)?.let {
val encryptionProtoIndex: Int = if (it.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2)) {
Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2
} else if (it.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) {
Constants.ARROYO_ENCRYPTION_PROTO_INDEX
} else {
return resultInputStream
}
resultInputStream = decryptInputStream(
resultInputStream,
encryptionProtoIndex == Constants.ARROYO_ENCRYPTION_PROTO_INDEX_V2,
it,
encryptionProtoIndex
)
}
return resultInputStream
}
fun decryptInputStream(
inputStream: InputStream,
base64Encryption: Boolean,
mediaInfoProto: ProtoReader,
encryptionProtoIndex: Int
): InputStream {
val mediaEncryption = mediaInfoProto.readPath(encryptionProtoIndex)!!
var key: ByteArray = mediaEncryption.getByteArray(1)!!
var iv: ByteArray = mediaEncryption.getByteArray(2)!!
//audio note and external medias have their key and iv encoded in base64
if (base64Encryption) {
val decoder = Base64.getMimeDecoder()
key = decoder.decode(key)
iv = decoder.decode(iv)
}
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv))
return CipherInputStream(inputStream, cipher)
}
}

View File

@ -0,0 +1,41 @@
package me.rhunk.snapenhance.util
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaDataSource
import android.media.MediaMetadataRetriever
object PreviewUtils {
fun createPreview(data: ByteArray, isVideo: Boolean): Bitmap? {
if (!isVideo) {
return BitmapFactory.decodeByteArray(data, 0, data.size)
}
val retriever = MediaMetadataRetriever()
retriever.setDataSource(object : MediaDataSource() {
override fun readAt(
position: Long,
buffer: ByteArray,
offset: Int,
size: Int
): Int {
var newSize = size
val length = data.size
if (position >= length) {
return -1
}
if (position + newSize > length) {
newSize = length - position.toInt()
}
System.arraycopy(data, position.toInt(), buffer, offset, newSize)
return newSize
}
override fun getSize(): Long {
return data.size.toLong()
}
override fun close() {}
})
return retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
}
}

View File

@ -0,0 +1,118 @@
package me.rhunk.snapenhance.util
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.util.*
object ReflectionHelper {
/**
* Searches for a field with a class that has a method with the specified name
*/
fun searchFieldWithClassMethod(clazz: Class<*>, methodName: String): Field? {
return clazz.declaredFields.firstOrNull { f: Field? ->
try {
return@firstOrNull Arrays.stream(
f!!.type.declaredMethods
).anyMatch { method: Method -> method.name == methodName }
} catch (e: Exception) {
return@firstOrNull false
}
}
}
fun searchFieldByType(clazz: Class<*>, type: Class<*>): Field? {
return clazz.declaredFields.firstOrNull { f: Field? -> f!!.type == type }
}
fun searchFieldTypeInSuperClasses(clazz: Class<*>, type: Class<*>): Field? {
val field = searchFieldByType(clazz, type)
if (field != null) {
return field
}
val superclass = clazz.superclass
return superclass?.let { searchFieldTypeInSuperClasses(it, type) }
}
fun searchFieldStartsWithToString(
clazz: Class<*>,
instance: Any,
toString: String?
): Field? {
return clazz.declaredFields.firstOrNull { f: Field ->
try {
f.isAccessible = true
return@firstOrNull Objects.requireNonNull(f[instance]).toString()
.startsWith(
toString!!
)
} catch (e: Throwable) {
return@firstOrNull false
}
}
}
fun searchFieldContainsToString(
clazz: Class<*>,
instance: Any?,
toString: String?
): Field? {
return clazz.declaredFields.firstOrNull { f: Field ->
try {
f.isAccessible = true
return@firstOrNull Objects.requireNonNull(f[instance]).toString()
.contains(toString!!)
} catch (e: Throwable) {
return@firstOrNull false
}
}
}
fun searchFirstFieldTypeInClassRecursive(clazz: Class<*>, type: Class<*>): Field? {
return clazz.declaredFields.firstOrNull {
val field = searchFieldByType(it.type, type)
return@firstOrNull field != null
}
}
/**
* Searches for a field with a class that has a method with the specified return type
*/
fun searchMethodWithReturnType(clazz: Class<*>, returnType: Class<*>): Method? {
return clazz.declaredMethods.first { m: Method -> m.returnType == returnType }
}
/**
* Searches for a field with a class that has a method with the specified return type and parameter types
*/
fun searchMethodWithParameterAndReturnType(
aClass: Class<*>,
returnType: Class<*>,
vararg parameters: Class<*>
): Method? {
return aClass.declaredMethods.firstOrNull { m: Method ->
if (m.returnType != returnType) {
return@firstOrNull false
}
val parameterTypes = m.parameterTypes
if (parameterTypes.size != parameters.size) {
return@firstOrNull false
}
for (i in parameterTypes.indices) {
if (parameterTypes[i] != parameters[i]) {
return@firstOrNull false
}
}
true
}
}
fun getDeclaredFieldsRecursively(clazz: Class<*>): List<Field> {
val fields = clazz.declaredFields.toMutableList()
val superclass = clazz.superclass
if (superclass != null) {
fields.addAll(getDeclaredFieldsRecursively(superclass))
}
return fields
}
}

View File

@ -0,0 +1,20 @@
package me.rhunk.snapenhance.util
import de.robv.android.xposed.XposedHelpers
fun Any.getObjectField(fieldName: String): Any {
return XposedHelpers.getObjectField(this, fieldName)
}
fun Any.setObjectField(fieldName: String, value: Any) {
XposedHelpers.setObjectField(this, fieldName, value)
}
fun Any.getObjectFieldOrNull(fieldName: String): Any? {
return try {
getObjectField(fieldName)
} catch (e: Exception) {
null
}
}

View File

@ -0,0 +1,83 @@
package me.rhunk.snapenhance.util.download
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import me.rhunk.snapenhance.Constants
import java.io.InputStream
import java.net.URL
import javax.net.ssl.HttpsURLConnection
object CdnDownloader {
const val BOLT_CDN_U = "https://bolt-gcdn.sc-cdn.net/u/"
const val BOLT_CDN_X = "https://bolt-gcdn.sc-cdn.net/x/"
const val CF_ST_CDN_D = "https://cf-st.sc-cdn.net/d/"
const val CF_ST_CDN_F = "https://cf-st.sc-cdn.net/f/"
const val CF_ST_CDN_H = "https://cf-st.sc-cdn.net/h/"
const val CF_ST_CDN_G = "https://cf-st.sc-cdn.net/g/"
const val CF_ST_CDN_O = "https://cf-st.sc-cdn.net/o/"
const val CF_ST_CDN_I = "https://cf-st.sc-cdn.net/i/"
const val CF_ST_CDN_J = "https://cf-st.sc-cdn.net/j/"
const val CF_ST_CDN_C = "https://cf-st.sc-cdn.net/c/"
const val CF_ST_CDN_AA = "https://cf-st.sc-cdn.net/aa/"
private val keyCache: MutableMap<String, String> = mutableMapOf()
fun downloadRemoteContent(
key: String,
vararg endpoints: String
): InputStream? = runBlocking {
if (keyCache.containsKey(key)) {
return@runBlocking queryRemoteContent(
keyCache[key]!!
)
}
val jobs = mutableListOf<Job>()
var inputStream: InputStream? = null
endpoints.forEach {
launch {
val url = it + key
val result = queryRemoteContent(url)
if (result != null) {
keyCache[key] = url
inputStream = result
jobs.forEach { it.cancel() }
}
}.also { jobs.add(it) }
}
jobs.forEach { it.join() }
inputStream
}
private fun queryRemoteContent(url: String): InputStream? {
try {
val connection = URL(url).openConnection() as HttpsURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 5000
connection.setRequestProperty("User-Agent", Constants.USER_AGENT)
return connection.inputStream
} catch (ignored: Throwable) {
}
return null
}
//TODO: automatically detect the correct endpoint
fun downloadWithDefaultEndpoints(key: String): InputStream? {
return downloadRemoteContent(
key,
CF_ST_CDN_F,
CF_ST_CDN_H,
BOLT_CDN_U,
BOLT_CDN_X,
CF_ST_CDN_O,
CF_ST_CDN_I,
CF_ST_CDN_C,
CF_ST_CDN_J,
CF_ST_CDN_AA,
CF_ST_CDN_G,
CF_ST_CDN_D
)
}
}

View File

@ -0,0 +1,116 @@
package me.rhunk.snapenhance.util.download
import de.robv.android.xposed.XposedBridge
import me.rhunk.snapenhance.Logger
import me.rhunk.snapenhance.Logger.debug
import me.rhunk.snapenhance.ModContext
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.io.PrintWriter
import java.net.ServerSocket
import java.net.Socket
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ThreadLocalRandom
import java.util.function.Consumer
class DownloadServer(
private val context: ModContext
) {
private val port = ThreadLocalRandom.current().nextInt(10000, 65535)
private val cachedData = ConcurrentHashMap<String, ByteArray>()
private var serverSocket: ServerSocket? = null
fun startFileDownload(destination: File, content: ByteArray, callback: Consumer<Boolean>) {
val httpKey = java.lang.Long.toHexString(System.nanoTime())
ensureServerStarted {
putDownloadableContent(httpKey, content)
val url = "http://127.0.0.1:$port/$httpKey"
context.executeAsync {
val result: Boolean = context.bridgeClient.downloadContent(url, destination.absolutePath)
callback.accept(result)
}
}
}
private fun ensureServerStarted(callback: Runnable) {
if (serverSocket != null && !serverSocket!!.isClosed) {
callback.run()
return
}
Thread {
try {
debug("started web server on 127.0.0.1:$port")
serverSocket = ServerSocket(port)
callback.run()
while (!serverSocket!!.isClosed) {
try {
val socket = serverSocket!!.accept()
Thread { handleRequest(socket) }.start()
} catch (e: Throwable) {
Logger.xposedLog(e)
}
}
} catch (e: Throwable) {
Logger.xposedLog(e)
}
}.start()
}
fun putDownloadableContent(key: String, data: ByteArray) {
cachedData[key] = data
}
private fun handleRequest(socket: Socket) {
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val outputStream = socket.getOutputStream()
val writer = PrintWriter(outputStream)
val line = reader.readLine() ?: return
val close = Runnable {
try {
reader.close()
writer.close()
outputStream.close()
socket.close()
} catch (e: Throwable) {
Logger.xposedLog(e)
}
}
val parse = StringTokenizer(line)
val method = parse.nextToken().uppercase(Locale.getDefault())
var fileRequested = parse.nextToken().lowercase(Locale.getDefault())
if (method != "GET") {
writer.println("HTTP/1.1 501 Not Implemented")
writer.println("Content-type: " + "application/octet-stream")
writer.println("Content-length: " + 0)
writer.println()
writer.flush()
close.run()
return
}
if (fileRequested.startsWith("/")) {
fileRequested = fileRequested.substring(1)
}
if (!cachedData.containsKey(fileRequested)) {
writer.println("HTTP/1.1 404 Not Found")
writer.println("Content-type: " + "application/octet-stream")
writer.println("Content-length: " + 0)
writer.println()
writer.flush()
close.run()
return
}
val data = cachedData[fileRequested]!!
writer.println("HTTP/1.1 200 OK")
writer.println("Content-type: " + "application/octet-stream")
writer.println("Content-length: " + data.size)
writer.println()
writer.flush()
outputStream.write(data, 0, data.size)
outputStream.flush()
close.run()
cachedData.remove(fileRequested)
}
}

View File

@ -0,0 +1,122 @@
package me.rhunk.snapenhance.util.protobuf
data class Wire(val type: Int, val value: Any)
class ProtoReader(private val buffer: ByteArray) {
private var offset: Int = 0
private val values = mutableMapOf<Int, MutableList<Wire>>()
init {
read()
}
fun getBuffer() = buffer
private fun readByte() = buffer[offset++]
private fun readVarInt(): Long {
var result = 0L
var shift = 0
while (true) {
val b = readByte()
result = result or ((b.toLong() and 0x7F) shl shift)
if (b.toInt() and 0x80 == 0) {
break
}
shift += 7
}
return result
}
private fun read() {
while (offset < buffer.size) {
val tag = readVarInt().toInt()
val id = tag ushr 3
val type = tag and 0x7
try {
val value = when (type) {
0 -> readVarInt().toString().toByteArray()
2 -> {
val length = readVarInt().toInt()
val value = buffer.copyOfRange(offset, offset + length)
offset += length
value
}
else -> break
}
values.getOrPut(id) { mutableListOf() }.add(Wire(type, value))
} catch (t: Throwable) {
values.clear()
break
}
}
}
fun readPath(vararg ids: Int, reader: (ProtoReader.() -> Unit)? = null): ProtoReader? {
var thisReader = this
ids.forEach { id ->
if (!thisReader.exists(id)) {
return null
}
thisReader = ProtoReader(thisReader.get(id) as ByteArray)
}
if (reader != null) {
thisReader.reader()
}
return thisReader
}
fun pathExists(vararg ids: Int): Boolean {
var thisReader = this
ids.forEach { id ->
if (!thisReader.exists(id)) {
return false
}
thisReader = ProtoReader(thisReader.get(id) as ByteArray)
}
return true
}
fun getByteArray(id: Int) = values[id]?.first()?.value as ByteArray?
fun getByteArray(vararg ids: Int): ByteArray? {
if (ids.isEmpty() || ids.size < 2) {
return null
}
val lastId = ids.last()
var value: ByteArray? = null
readPath(*(ids.copyOfRange(0, ids.size - 1))) {
value = getByteArray(lastId)
}
return value
}
fun getString(id: Int) = getByteArray(id)?.toString(Charsets.UTF_8)
fun getString(vararg ids: Int) = getByteArray(*ids)?.toString(Charsets.UTF_8)
fun getInt(id: Int) = getString(id)?.toInt()
fun getInt(vararg ids: Int) = getString(*ids)?.toInt()
fun getLong(id: Int) = getString(id)?.toLong()
fun getLong(vararg ids: Int) = getString(*ids)?.toLong()
fun exists(id: Int) = values.containsKey(id)
fun get(id: Int) = values[id]!!.first().value
fun isValid() = values.isNotEmpty()
fun getCount(id: Int) = values[id]!!.size
fun each(id: Int, reader: ProtoReader.(index: Int) -> Unit) {
values[id]!!.forEachIndexed { index, _ ->
ProtoReader(values[id]!![index].value as ByteArray).reader(index)
}
}
fun eachExists(id: Int, reader: ProtoReader.(index: Int) -> Unit) {
if (!exists(id)) {
return
}
each(id, reader)
}
}

View File

@ -0,0 +1,66 @@
package me.rhunk.snapenhance.util.protobuf
import java.io.ByteArrayOutputStream
class ProtoWriter {
private val stream: ByteArrayOutputStream = ByteArrayOutputStream()
private fun writeVarInt(value: Int) {
var v = value
while (v and -0x80 != 0) {
stream.write(v and 0x7F or 0x80)
v = v ushr 7
}
stream.write(v)
}
private fun writeVarLong(value: Long) {
var v = value
while (v and -0x80L != 0L) {
stream.write((v and 0x7FL or 0x80L).toInt())
v = v ushr 7
}
stream.write(v.toInt())
}
fun writeBuffer(id: Int, value: ByteArray) {
writeVarInt(id shl 3 or 2)
writeVarInt(value.size)
stream.write(value)
}
fun writeConstant(id: Int, value: Int) {
writeVarInt(id shl 3)
writeVarInt(value)
}
fun writeConstant(id: Int, value: Long) {
writeVarInt(id shl 3)
writeVarLong(value)
}
fun writeString(id: Int, value: String) = writeBuffer(id, value.toByteArray())
fun write(id: Int, writer: ProtoWriter.() -> Unit) {
val writerStream = ProtoWriter()
writer(writerStream)
writeBuffer(id, writerStream.stream.toByteArray())
}
fun write(vararg ids: Int, writer: ProtoWriter.() -> Unit) {
val writerStream = ProtoWriter()
writer(writerStream)
var stream = writerStream.stream.toByteArray()
ids.reversed().forEach { id ->
with(ProtoWriter()) {
writeBuffer(id, stream)
stream = this.stream.toByteArray()
}
}
stream.let(this.stream::write)
}
fun toByteArray(): ByteArray {
return stream.toByteArray()
}
}

View File

@ -0,0 +1,2 @@
package me.rhunk.snapenhance.util.snap

Some files were not shown because too many files have changed in this diff Show More