commit 065068666783d9df699b0b8203f93b70dcc3b4d4
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date: Mon May 15 00:37:29 2023 +0200
initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..faf530b2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+*.iml
+.gradle
+/local.properties
+/.idea/
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..e32d46ae
--- /dev/null
+++ b/README.md
@@ -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
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..c9db3d0b
--- /dev/null
+++ b/app/.gitignore
@@ -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
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 00000000..ac941a87
--- /dev/null
+++ b/app/build.gradle
@@ -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'
+}
\ No newline at end of file
diff --git a/app/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar b/app/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar
new file mode 100644
index 00000000..139c4717
Binary files /dev/null and b/app/libs/LSPosed-api-1.0-SNAPSHOT-javadoc.jar differ
diff --git a/app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar b/app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar
new file mode 100644
index 00000000..f77b0ffd
Binary files /dev/null and b/app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar differ
diff --git a/app/libs/LSPosed-api-1.0-SNAPSHOT.jar b/app/libs/LSPosed-api-1.0-SNAPSHOT.jar
new file mode 100644
index 00000000..9bdb009e
Binary files /dev/null and b/app/libs/LSPosed-api-1.0-SNAPSHOT.jar differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -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
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..236adf1b
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/xposed_init b/app/src/main/assets/xposed_init
new file mode 100644
index 00000000..6f814117
--- /dev/null
+++ b/app/src/main/assets/xposed_init
@@ -0,0 +1 @@
+me.rhunk.snapenhance.XposedLoader
\ No newline at end of file
diff --git a/app/src/main/java/me/rhunk/snapenhance/XposedLoader.java b/app/src/main/java/me/rhunk/snapenhance/XposedLoader.java
new file mode 100644
index 00000000..492e70a3
--- /dev/null
+++ b/app/src/main/java/me/rhunk/snapenhance/XposedLoader.java
@@ -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();
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt b/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt
new file mode 100644
index 00000000..b29297c5
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/Constants.kt
@@ -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"
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt b/app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt
new file mode 100644
index 00000000..9cc4b89d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/Logger.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt
new file mode 100644
index 00000000..7744688d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/ModContext.kt
@@ -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 feature(featureClass: KClass): 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt b/app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt
new file mode 100644
index 00000000..d235f2b2
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/SnapEnhance.kt
@@ -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(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()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/BridgeClient.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/BridgeClient.kt
new file mode 100644
index 00000000..ef13f8b9
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/client/BridgeClient.kt
@@ -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
+
+ 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
+ ) {
+ 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 sendMessage(
+ messageType: BridgeMessageType,
+ message: BridgeMessage,
+ resultType: KClass? = null
+ ): T {
+ val future = CompletableFuture()
+
+ 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)
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessage.kt
new file mode 100644
index 00000000..e12e59d7
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessage.kt
@@ -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
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessageType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessageType.kt
new file mode 100644
index 00000000..496123ae
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/BridgeMessageType.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/DownloadContentRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/DownloadContentRequest.kt
new file mode 100644
index 00000000..7c8f9c53
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/DownloadContentRequest.kt
@@ -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")
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/DownloadContentResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/DownloadContentResult.kt
new file mode 100644
index 00000000..ef7f33f5
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/DownloadContentResult.kt
@@ -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")
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/FileAccessRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/FileAccessRequest.kt
new file mode 100644
index 00000000..5ba5b83d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/FileAccessRequest.kt
@@ -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 }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/FileAccessResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/FileAccessResult.kt
new file mode 100644
index 00000000..ff616a40
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/FileAccessResult.kt
@@ -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")
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/LocaleRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/LocaleRequest.kt
new file mode 100644
index 00000000..da2a1bc0
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/LocaleRequest.kt
@@ -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")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/LocaleResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/LocaleResult.kt
new file mode 100644
index 00000000..0e1fa191
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/LocaleResult.kt
@@ -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")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/MessageLoggerRequest.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/MessageLoggerRequest.kt
new file mode 100644
index 00000000..3040af58
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/MessageLoggerRequest.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/MessageLoggerResult.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/MessageLoggerResult.kt
new file mode 100644
index 00000000..b904c2a6
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/common/impl/MessageLoggerResult.kt
@@ -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")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/BridgeService.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/BridgeService.kt
new file mode 100644
index 00000000..5f32a424
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/BridgeService.kt
@@ -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))
+ }
+
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/MainActivity.kt b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/MainActivity.kt
new file mode 100644
index 00000000..9725222b
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/bridge/service/MainActivity.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigAccessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigAccessor.kt
new file mode 100644
index 00000000..4644aaf2
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigAccessor.kt
@@ -0,0 +1,58 @@
+package me.rhunk.snapenhance.config
+
+open class ConfigAccessor(
+ private val configMap: MutableMap
+) {
+ 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 list(key: ConfigProperty): List {
+ return get(key) as List
+ }
+
+ fun get(key: ConfigProperty): Any? {
+ return configMap[key]
+ }
+
+ fun set(key: ConfigProperty, value: Any?) {
+ configMap[key] = value
+ }
+
+ fun entries(): Set> {
+ return configMap.entries
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigCategory.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigCategory.kt
new file mode 100644
index 00000000..590af4ac
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigCategory.kt
@@ -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");
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt
new file mode 100644
index 00000000..0f399b10
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/config/ConfigProperty.kt
@@ -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 {
+ return values().sortedBy { it.category.ordinal }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt
new file mode 100644
index 00000000..0b8c506f
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/FileType.kt
@@ -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()
+
+ 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
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt
new file mode 100644
index 00000000..be6eb266
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt
new file mode 100644
index 00000000..ea684430
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapEnums.kt
@@ -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
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt
new file mode 100644
index 00000000..e8c4cdb3
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/AbstractWrapper.kt
@@ -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 > 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>
+ XposedHelpers.setObjectField(instance, fieldName, java.lang.Enum.valueOf(type, value.name))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt
new file mode 100644
index 00000000..3c84aff0
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/Message.kt
@@ -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"))
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt
new file mode 100644
index 00000000..7c3b010f
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageContent.kt
@@ -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)
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt
new file mode 100644
index 00000000..ee2a37cc
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageDescriptor.kt
@@ -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"))
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt
new file mode 100644
index 00000000..b32954e5
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/MessageMetadata.kt
@@ -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 = (instance.getObjectField("mSavedBy") as List<*>).map { SnapUUID(it!!) }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt
new file mode 100644
index 00000000..f462509e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/SnapUUID.kt
@@ -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())
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/EncryptionWrapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/EncryptionWrapper.kt
new file mode 100644
index 00000000..316baf64
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/EncryptionWrapper.kt
@@ -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
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt
new file mode 100644
index 00000000..85139a76
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/MediaInfo.kt
@@ -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) }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt
new file mode 100644
index 00000000..81c2679e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/Layer.kt
@@ -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]]!!)
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt
new file mode 100644
index 00000000..a3258edd
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/LayerController.kt
@@ -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))
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt
new file mode 100644
index 00000000..8b20c26e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/data/wrapper/impl/media/opera/ParamMap.kt
@@ -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
+ get() = instance.getObjectField(paramMapField.name) as ConcurrentHashMap
+
+ 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()
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt
new file mode 100644
index 00000000..d65b039c
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt
@@ -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 safeDatabaseOperation(
+ database: SQLiteDatabase,
+ query: (SQLiteDatabase) -> T?
+ ): T? {
+ synchronized(databaseLock) {
+ return runCatching {
+ query(database)
+ }.onFailure {
+ Logger.xposedLog("Database operation failed", it)
+ }.getOrNull()
+ }
+ }
+
+ private fun readDatabaseObject(
+ obj: T,
+ database: SQLiteDatabase,
+ table: String,
+ where: String,
+ args: Array
+ ): 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? {
+ 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()
+ 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? {
+ 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()
+ do {
+ val message = ConversationMessage()
+ message.write(cursor)
+ messages.add(message)
+ } while (cursor.moveToNext())
+ cursor.close()
+ messages
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt
new file mode 100644
index 00000000..d54f2553
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseObject.kt
@@ -0,0 +1,7 @@
+package me.rhunk.snapenhance.database
+
+import android.database.Cursor
+
+interface DatabaseObject {
+ fun write(cursor: Cursor)
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt
new file mode 100644
index 00000000..0e97373d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/ConversationMessage.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedInfo.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedInfo.kt
new file mode 100644
index 00000000..03ad5574
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendFeedInfo.kt
@@ -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"))
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt
new file mode 100644
index 00000000..ac1b29c7
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/FriendInfo.kt
@@ -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"))
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt
new file mode 100644
index 00000000..f0001ca5
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/StoryEntry.kt
@@ -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"))
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt
new file mode 100644
index 00000000..e44c019e
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/database/objects/UserConversationLink.kt
@@ -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"))
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/event/EventBus.kt b/app/src/main/kotlin/me/rhunk/snapenhance/event/EventBus.kt
new file mode 100644
index 00000000..dca07ff9
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/event/EventBus.kt
@@ -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 {
+ fun handle(event: T)
+}
+
+class EventBus(
+ private val context: ModContext
+) {
+ private val subscribers = mutableMapOf, MutableList>>()
+
+ fun subscribe(event: KClass, listener: IListener) {
+ if (!subscribers.containsKey(event)) {
+ subscribers[event] = mutableListOf()
+ }
+ subscribers[event]!!.add(listener)
+ }
+
+ fun subscribe(event: KClass, listener: (T) -> Unit) {
+ subscribe(event, object : IListener {
+ override fun handle(event: T) {
+ listener(event)
+ }
+ })
+ }
+
+ fun unsubscribe(event: KClass, listener: IListener) {
+ if (!subscribers.containsKey(event)) {
+ return
+ }
+ subscribers[event]!!.remove(listener)
+ }
+
+ fun post(event: T) {
+ if (!subscribers.containsKey(event::class)) {
+ return
+ }
+
+ event.context = context
+
+ subscribers[event::class]!!.forEach { listener ->
+ @Suppress("UNCHECKED_CAST")
+ try {
+ (listener as IListener).handle(event)
+ } catch (t: Throwable) {
+ println("Error while handling event ${event::class.simpleName} by ${listener::class.simpleName}")
+ t.printStackTrace()
+ }
+ }
+ }
+
+ fun clear() {
+ subscribers.clear()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/event/Events.kt b/app/src/main/kotlin/me/rhunk/snapenhance/event/Events.kt
new file mode 100644
index 00000000..aae56c2d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/event/Events.kt
@@ -0,0 +1,3 @@
+package me.rhunk.snapenhance.event
+
+//TODO: addView event
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt
new file mode 100644
index 00000000..ab632492
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/Feature.kt
@@ -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() {}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt
new file mode 100644
index 00000000..fbbbc2f4
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/FeatureLoadParams.kt
@@ -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
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigEnumKeys.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigEnumKeys.kt
new file mode 100644
index 00000000..7565c52d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ConfigEnumKeys.kt
@@ -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) -> 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")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt
new file mode 100644
index 00000000..fd0f4b9f
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/Messaging.kt
@@ -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 = 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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt
new file mode 100644
index 00000000..c6b55613
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/downloader/MediaDownloader.kt
@@ -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? = 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()
+ 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, 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,
+ 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()
+ 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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/AutoSave.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/AutoSave.kt
new file mode 100644
index 00000000..aa640b16
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/AutoSave.kt
@@ -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(0).getObjectField("mConversationId"))
+ val messages = param.arg>(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)
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/Notifications.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/Notifications.kt
new file mode 100644
index 00000000..735dbc4f
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/Notifications.kt
@@ -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()
+ private val cachedNotifications = mutableMapOf>()
+
+ 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) {
+ 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).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
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/SnapchatPlus.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/SnapchatPlus.kt
new file mode 100644
index 00000000..4b89914c
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/extras/SnapchatPlus.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/DisableMetrics.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/DisableMetrics.kt
new file mode 100644
index 00000000..f0f12d55
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/DisableMetrics.kt
@@ -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)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventScreenshotDetections.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventScreenshotDetections.kt
new file mode 100644
index 00000000..57491e88
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/privacy/PreventScreenshotDetections.kt
@@ -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)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/AnonymousStoryViewing.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/AnonymousStoryViewing.kt
new file mode 100644
index 00000000..86028fe2
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/AnonymousStoryViewing.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/MessageLogger.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/MessageLogger.kt
new file mode 100644
index 00000000..1cdfc8ee
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/MessageLogger.kt
@@ -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()
+ private val removedMessages = linkedSetOf()
+
+ 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()
+ 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))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/PreventReadReceipts.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/PreventReadReceipts.kt
new file mode 100644
index 00000000..47285ebb
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/PreventReadReceipts.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/PreventStatusNotifications.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/PreventStatusNotifications.kt
new file mode 100644
index 00000000..d30f239a
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/PreventStatusNotifications.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/StealthMode.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/StealthMode.kt
new file mode 100644
index 00000000..a2bc6ee4
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/spy/StealthMode.kt
@@ -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()
+
+ 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()
+ 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))
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt
new file mode 100644
index 00000000..c887f951
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/UITweaks.kt
@@ -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)
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt
new file mode 100644
index 00000000..a00abe23
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/AbstractMenu.kt
@@ -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() {}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt
new file mode 100644
index 00000000..9eef82e3
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/MenuViewInjector.kt
@@ -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)
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt
new file mode 100644
index 00000000..93e86a41
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/ViewAppearanceHelper.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt
new file mode 100644
index 00000000..b4bd0317
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/ChatActionMenu.kt
@@ -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
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt
new file mode 100644
index 00000000..d1335cad
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/FriendFeedInfoMenu.kt
@@ -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? = 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 = 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt
new file mode 100644
index 00000000..ef4dbf25
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/OperaContextActionMenu.kt
@@ -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()
+ 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()
+ 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)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt
new file mode 100644
index 00000000..073f3e57
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/features/impl/ui/menus/impl/SettingsMenu.kt
@@ -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))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt b/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt
new file mode 100644
index 00000000..2765b6ee
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookAdapter.kt
@@ -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 thisObject(): T {
+ return methodHookParam.thisObject as T
+ }
+
+ fun method(): Member {
+ return methodHookParam.method
+ }
+
+ fun arg(index: Int): T {
+ return methodHookParam.args[index] as T
+ }
+
+ fun 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 {
+ 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? {
+ return XposedBridge.invokeOriginalMethod(method(), thisObject(), args)
+ }
+
+ fun invokeOriginalSafe(errorCallback: Consumer) {
+ invokeOriginalSafe(args(), errorCallback)
+ }
+
+ fun invokeOriginalSafe(args: Array, errorCallback: Consumer) {
+ runCatching {
+ setResult(XposedBridge.invokeOriginalMethod(method(), thisObject(), args))
+ }.onFailure {
+ errorCallback.accept(it)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookStage.kt b/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookStage.kt
new file mode 100644
index 00000000..dddf1342
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/hook/HookStage.kt
@@ -0,0 +1,6 @@
+package me.rhunk.snapenhance.hook
+
+enum class HookStage {
+ BEFORE,
+ AFTER
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt b/app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt
new file mode 100644
index 00000000..92f558e4
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/hook/Hooker.kt
@@ -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 = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer))
+
+ fun hook(
+ clazz: Class<*>,
+ methodName: String,
+ stage: HookStage,
+ filter: (HookAdapter) -> Boolean,
+ consumer: (HookAdapter) -> Unit
+ ): Set = 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 = HashSet()
+ hook(clazz, methodName, stage) { param->
+ if (param.thisObject() != instance) return@hook
+ hookConsumer(param)
+ unhooks.forEach{ it.unhook() }
+ }.also { unhooks.addAll(it) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/Manager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/Manager.kt
new file mode 100644
index 00000000..ba244213
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/manager/Manager.kt
@@ -0,0 +1,6 @@
+package me.rhunk.snapenhance.manager
+
+interface Manager {
+ fun init() {}
+ fun onActivityCreate() {}
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ConfigManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ConfigManager.kt
new file mode 100644
index 00000000..dbd8540a
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/ConfigManager.kt
@@ -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 = 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)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt
new file mode 100644
index 00000000..83f3b979
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/FeatureManager.kt
@@ -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()
+
+ private fun register(featureClass: KClass) {
+ 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 get(featureClass: KClass): 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() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/MappingManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/MappingManager.kt
new file mode 100644
index 00000000..f49e10fe
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/MappingManager.kt
@@ -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().apply {
+ add(CallbackMapper())
+ add(EnumMapper())
+ add(OperaPageViewControllerMapper())
+ add(PlusSubscriptionMapper())
+ }
+
+ private val mappings = ConcurrentHashMap()
+ 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 ->
+ 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>) = runBlocking {
+ val jobs = mutableListOf()
+ 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> = ArrayList()
+
+ val classLoader = context.androidContext.classLoader
+ val dexPathList = classLoader.getObjectField("pathList")
+ val dexElements = dexPathList.getObjectField("dexElements") as Array
+
+ 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 getMappedList(key: String): List {
+ return listOf(getMappedObject(key) as List).flatten()
+ }
+
+ fun getMappedValue(key: String, subKey: String): String {
+ return getMappedMap(key)[subKey] as String
+ }
+
+ fun getMappedMap(key: String): Map {
+ return getMappedObject(key) as Map
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/TranslationManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/TranslationManager.kt
new file mode 100644
index 00000000..5f99d8a2
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/manager/impl/TranslationManager.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt
new file mode 100644
index 00000000..c9f3df16
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/Mapper.kt
@@ -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>,
+ mappings: MutableMap
+ )
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/CallbackMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/CallbackMapper.kt
new file mode 100644
index 00000000..6bca4730
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/CallbackMapper.kt
@@ -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>,
+ mappings: MutableMap
+ ) {
+ val callbackMappings = HashMap()
+ 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
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/EnumMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/EnumMapper.kt
new file mode 100644
index 00000000..19b3c931
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/EnumMapper.kt
@@ -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>,
+ mappings: MutableMap
+ ) {
+ val enumMappings = HashMap()
+ //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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/OperaPageViewControllerMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/OperaPageViewControllerMapper.kt
new file mode 100644
index 00000000..82bd9d03
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/OperaPageViewControllerMapper.kt
@@ -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>,
+ mappings: MutableMap
+ ) {
+ 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()
+ 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
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlusSubscriptionMapper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlusSubscriptionMapper.kt
new file mode 100644
index 00000000..96aecee6
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/mapping/impl/PlusSubscriptionMapper.kt
@@ -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>,
+ mappings: MutableMap
+ ) {
+ //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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt
new file mode 100644
index 00000000..8559712d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/CallbackBuilder.kt
@@ -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()
+
+ 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()
+
+ 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)
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt
new file mode 100644
index 00000000..5eee049d
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/EncryptionHelper.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt
new file mode 100644
index 00000000..a5a288b0
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/PreviewCreator.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt
new file mode 100644
index 00000000..e3ed6b84
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/ReflectionHelper.kt
@@ -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 {
+ val fields = clazz.declaredFields.toMutableList()
+ val superclass = clazz.superclass
+ if (superclass != null) {
+ fields.addAll(getDeclaredFieldsRecursively(superclass))
+ }
+ return fields
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/XposedHelperMacros.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/XposedHelperMacros.kt
new file mode 100644
index 00000000..c73877a1
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/XposedHelperMacros.kt
@@ -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
+ }
+}
+
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/CdnDownloader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/CdnDownloader.kt
new file mode 100644
index 00000000..fbca8858
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/CdnDownloader.kt
@@ -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 = mutableMapOf()
+
+ fun downloadRemoteContent(
+ key: String,
+ vararg endpoints: String
+ ): InputStream? = runBlocking {
+ if (keyCache.containsKey(key)) {
+ return@runBlocking queryRemoteContent(
+ keyCache[key]!!
+ )
+ }
+ val jobs = mutableListOf()
+ 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
+ )
+ }
+}
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt
new file mode 100644
index 00000000..712e24fc
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/download/DownloadServer.kt
@@ -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()
+ private var serverSocket: ServerSocket? = null
+
+ fun startFileDownload(destination: File, content: ByteArray, callback: Consumer) {
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt
new file mode 100644
index 00000000..f5f28ece
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoReader.kt
@@ -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>()
+
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt
new file mode 100644
index 00000000..f12eaa1a
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/protobuf/ProtoWriter.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt
new file mode 100644
index 00000000..a4c9508b
--- /dev/null
+++ b/app/src/main/kotlin/me/rhunk/snapenhance/util/snap/SnapUUID.kt
@@ -0,0 +1,2 @@
+package me.rhunk.snapenhance.util.snap
+
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
new file mode 100644
index 00000000..0d2c4cc4
--- /dev/null
+++ b/app/src/main/res/values-fr/strings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
new file mode 100644
index 00000000..3ef365ea
--- /dev/null
+++ b/app/src/main/res/values/arrays.xml
@@ -0,0 +1,6 @@
+
+
+
+ - com.snapchat.android
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..0d7eee6b
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,33 @@
+
+ Snap Enhance
+ Save folder
+ Prevent read receipts
+ Hide Bitmoji presence
+ Show message content
+ Message logger
+ Media downloader feature
+ Download stories
+ Download public stories
+ Download spotlight
+ Overlay merge
+ Download in chat snaps
+ Disable metrics
+ Prevent screenshot
+ Anonymous story view
+ Hide typing notification
+ Menu slot id
+ Message preview length
+ Auto save
+ External media as snap
+ Conversation export
+ Snapchat Plus
+ Remove voice record button
+ Remove stickers button
+ Remove cognac button
+ Remove call buttons
+ Long snap sending
+ Block ads
+ Streak Expiration Info
+ New map ui
+ Use download manager
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 00000000..4b42505b
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,10 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '7.2.2' apply false
+ id 'com.android.library' version '7.2.2' apply false
+ id 'org.jetbrains.kotlin.android' version '1.8.21' apply false
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 00000000..cd0519bb
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e708b1c0
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..3582dbb3
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri May 12 21:23:16 CEST 2023
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
new file mode 100644
index 00000000..4f906e0c
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 00000000..107acd32
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 00000000..e06b8971
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "SnapEnhance"
+include ':app'