commit 065068666783d9df699b0b8203f93b70dcc3b4d4 Author: rhunk <101876869+rhunk@users.noreply.github.com> Date: Mon, 15 May 2023 00:37:29 +0200 initial commit Diffstat:
110 files changed, 6115 insertions(+), 0 deletions(-)
diff --git a/.gitignore 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 @@ -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 @@ -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 @@ -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 Binary files differ. diff --git a/app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar b/app/libs/LSPosed-api-1.0-SNAPSHOT-sources.jar Binary files differ. diff --git a/app/libs/LSPosed-api-1.0-SNAPSHOT.jar b/app/libs/LSPosed-api-1.0-SNAPSHOT.jar Binary files differ. diff --git a/app/proguard-rules.pro 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 @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + package="me.rhunk.snapenhance"> + + <uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" /> + <uses-permission android:name="android.permission.INTERNET" /> + + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" + tools:ignore="ScopedStorage" /> + <application + android:usesCleartextTraffic="true" + android:label="@string/app_name" + tools:targetApi="31"> + <meta-data + android:name="xposedmodule" + android:value="true" /> + <meta-data + android:name="xposeddescription" + android:value="Enhanced Snapchat" /> + <meta-data + android:name="xposedminversion" + android:value="53" /> + <meta-data + android:name="xposedscope" + android:resource="@array/sc_scope" /> + + <service + android:name=".bridge.service.BridgeService" + android:exported="true"> + </service> + + <activity + android:theme="@android:style/Theme.NoDisplay" + android:name=".bridge.service.MainActivity" + android:exported="true" + android:excludeFromRecents="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + + </application> + +</manifest>+ \ No newline at end of file diff --git a/app/src/main/assets/xposed_init 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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,96 @@ +package me.rhunk.snapenhance + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.widget.Toast +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import me.rhunk.snapenhance.bridge.client.BridgeClient +import me.rhunk.snapenhance.database.DatabaseAccess +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.manager.impl.ConfigManager +import me.rhunk.snapenhance.manager.impl.FeatureManager +import me.rhunk.snapenhance.manager.impl.MappingManager +import me.rhunk.snapenhance.manager.impl.TranslationManager +import me.rhunk.snapenhance.util.download.DownloadServer +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.reflect.KClass +import kotlin.system.exitProcess + +class ModContext { + private val executorService: ExecutorService = Executors.newCachedThreadPool() + + lateinit var androidContext: Context + var mainActivity: Activity? = null + + val gson: Gson = GsonBuilder().create() + + val bridgeClient = BridgeClient(this) + val translation = TranslationManager(this) + val features = FeatureManager(this) + val mappings = MappingManager(this) + val config = ConfigManager(this) + val database = DatabaseAccess(this) + val downloadServer = DownloadServer(this) + val classCache get() = SnapEnhance.classCache + val resources: Resources get() = androidContext.resources + + fun <T : Feature> feature(featureClass: KClass<T>): T { + return features.get(featureClass)!! + } + + fun runOnUiThread(runnable: () -> Unit) { + Handler(Looper.getMainLooper()).post { + runCatching(runnable).onFailure { + Logger.xposedLog("UI thread runnable failed", it) + } + } + } + + fun executeAsync(runnable: () -> Unit) { + executorService.submit { + runCatching { + runnable() + }.onFailure { + Logger.xposedLog("Async task failed", it) + } + } + } + + fun shortToast(message: Any) { + runOnUiThread { + Toast.makeText(androidContext, message.toString(), Toast.LENGTH_SHORT).show() + } + } + + fun longToast(message: Any) { + runOnUiThread { + Toast.makeText(androidContext, message.toString(), Toast.LENGTH_LONG).show() + } + } + + fun restartApp() { + androidContext.packageManager.getLaunchIntentForPackage( + Constants.SNAPCHAT_PACKAGE_NAME + )?.let { + val intent = Intent.makeRestartActivityTask(it.component) + androidContext.startActivity(intent) + Runtime.getRuntime().exit(0) + } + } + + fun softRestartApp() { + exitProcess(0) + } + + fun forceCloseApp() { + Process.killProcess(Process.myPid()) + exitProcess(1) + } +}+ \ 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 @@ -0,0 +1,65 @@ +package me.rhunk.snapenhance + +import android.app.Activity +import android.app.Application +import android.content.Context +import me.rhunk.snapenhance.data.SnapClassCache +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker + +class SnapEnhance { + companion object { + lateinit var classLoader: ClassLoader + val classCache: SnapClassCache by lazy { + SnapClassCache(classLoader) + } + } + private val appContext = ModContext() + + init { + Hooker.hook(Application::class.java, "attach", HookStage.BEFORE) { param -> + appContext.androidContext = param.arg<Context>(0).also { + classLoader = it.classLoader + } + + appContext.bridgeClient.start { bridgeResult -> + if (!bridgeResult) { + Logger.xposedLog("Cannot connect to bridge service") + appContext.restartApp() + return@start + } + runCatching { + init() + }.onFailure { + Logger.xposedLog("Failed to initialize", it) + } + } + } + + Hooker.hook(Activity::class.java, "onCreate", HookStage.AFTER) { + val activity = it.thisObject() as Activity + if (!activity.packageName.equals(Constants.SNAPCHAT_PACKAGE_NAME)) return@hook + val isMainActivityNotNull = appContext.mainActivity != null + appContext.mainActivity = activity + if (isMainActivityNotNull) return@hook + onActivityCreate() + } + } + + private fun init() { + val time = System.currentTimeMillis() + with(appContext) { + translation.init() + config.init() + mappings.init() + features.init() + } + Logger.debug("initialized in ${System.currentTimeMillis() - time} ms") + } + + private fun onActivityCreate() { + with(appContext) { + features.onActivityCreate() + } + } +}+ \ 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 @@ -0,0 +1,238 @@ +package me.rhunk.snapenhance.bridge.client + + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.* +import me.rhunk.snapenhance.BuildConfig +import me.rhunk.snapenhance.Logger.log +import me.rhunk.snapenhance.Logger.xposedLog +import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.bridge.common.BridgeMessage +import me.rhunk.snapenhance.bridge.common.BridgeMessageType +import me.rhunk.snapenhance.bridge.common.impl.* +import me.rhunk.snapenhance.bridge.service.BridgeService +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import kotlin.reflect.KClass +import kotlin.system.exitProcess + + +class BridgeClient( + private val context: ModContext +) : ServiceConnection { + private val handlerThread = HandlerThread("BridgeClient") + + private lateinit var messenger: Messenger + private lateinit var future: CompletableFuture<Boolean> + + fun start(callback: (Boolean) -> Unit = {}) { + this.future = CompletableFuture() + this.handlerThread.start() + + with(context.androidContext) { + val intent = Intent() + .setClassName(BuildConfig.APPLICATION_ID, BridgeService::class.java.name) + bindService( + intent, + Context.BIND_AUTO_CREATE, + Executors.newSingleThreadExecutor(), + this@BridgeClient + ) + } + callback(future.get()) + } + + private fun handleResponseMessage( + msg: Message, + future: CompletableFuture<BridgeMessage> + ) { + val message: BridgeMessage = when (BridgeMessageType.fromValue(msg.what)) { + BridgeMessageType.FILE_ACCESS_RESULT -> FileAccessResult() + BridgeMessageType.DOWNLOAD_CONTENT_RESULT -> DownloadContentResult() + BridgeMessageType.MESSAGE_LOGGER_RESULT -> MessageLoggerResult() + else -> { + log("Unknown message type: ${msg.what}") + null + } + } ?: return + + with(message) { + read(msg.data) + future.complete(this) + } + } + + @Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER") + private fun <T : BridgeMessage> sendMessage( + messageType: BridgeMessageType, + message: BridgeMessage, + resultType: KClass<T>? = null + ): T { + val future = CompletableFuture<BridgeMessage>() + + val replyMessenger = Messenger(object : Handler(handlerThread.looper) { + override fun handleMessage(msg: Message) { + handleResponseMessage(msg, future) + } + }) + + runCatching { + with(Message.obtain()) { + what = messageType.value + replyTo = replyMessenger + data = Bundle() + message.write(data) + messenger.send(this) + } + } + + return future.get() as T + } + + /** + * Create a file if it doesn't exist, and read it + * + * @param fileType the type of file to create and read + * @param defaultContent the default content to write to the file if it doesn't exist + * @return the content of the file + */ + fun createAndReadFile( + fileType: FileAccessRequest.FileType, + defaultContent: ByteArray + ): ByteArray { + sendMessage( + BridgeMessageType.FILE_ACCESS_REQUEST, + FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null), + FileAccessResult::class + ).run { + if (state!!) { + return readFile(fileType) + } + writeFile(fileType, defaultContent) + return defaultContent + } + } + + /** + * Read a file + * + * @param fileType the type of file to read + * @return the content of the file + */ + fun readFile(fileType: FileAccessRequest.FileType): ByteArray { + sendMessage( + BridgeMessageType.FILE_ACCESS_REQUEST, + FileAccessRequest(FileAccessRequest.FileAccessAction.READ, fileType, null), + FileAccessResult::class + ).run { + return content!! + } + } + + /** + * Write a file + * + * @param fileType the type of file to write + * @param content the content to write to the file + * @return true if the file was written successfully + */ + fun writeFile( + fileType: FileAccessRequest.FileType, + content: ByteArray? + ): Boolean { + sendMessage( + BridgeMessageType.FILE_ACCESS_REQUEST, + FileAccessRequest(FileAccessRequest.FileAccessAction.WRITE, fileType, content), + FileAccessResult::class + ).run { + return state!! + } + } + + /** + * Delete a file + * + * @param fileType the type of file to delete + * @return true if the file was deleted successfully + */ + fun deleteFile(fileType: FileAccessRequest.FileType): Boolean { + sendMessage( + BridgeMessageType.FILE_ACCESS_REQUEST, + FileAccessRequest(FileAccessRequest.FileAccessAction.DELETE, fileType, null), + FileAccessResult::class + ).run { + return state!! + } + } + + /** + * Check if a file exists + * + * @param fileType the type of file to check + * @return true if the file exists + */ + + fun isFileExists(fileType: FileAccessRequest.FileType): Boolean { + sendMessage( + BridgeMessageType.FILE_ACCESS_REQUEST, + FileAccessRequest(FileAccessRequest.FileAccessAction.EXISTS, fileType, null), + FileAccessResult::class + ).run { + return state!! + } + } + + /** + * Download content from a URL and save it to a file + * + * @param url the URL to download content from + * @param path the path to save the content to + * @return true if the content was downloaded successfully + */ + fun downloadContent(url: String, path: String): Boolean { + sendMessage( + BridgeMessageType.DOWNLOAD_CONTENT_REQUEST, + DownloadContentRequest(url, path), + DownloadContentResult::class + ).run { + return state!! + } + } + + fun getMessageLoggerMessage(id: Long): ByteArray? { + sendMessage( + BridgeMessageType.MESSAGE_LOGGER_REQUEST, + MessageLoggerRequest(MessageLoggerRequest.Action.GET, id), + MessageLoggerResult::class + ).run { + return message + } + } + + fun addMessageLoggerMessage(id: Long, message: ByteArray) { + sendMessage( + BridgeMessageType.MESSAGE_LOGGER_REQUEST, + MessageLoggerRequest(MessageLoggerRequest.Action.ADD, id, message), + MessageLoggerResult::class + ) + } + + override fun onServiceConnected(name: ComponentName, service: IBinder) { + messenger = Messenger(service) + future.complete(true) + } + + override fun onNullBinding(name: ComponentName) { + xposedLog("failed to connect to bridge service") + future.complete(false) + } + + override fun onServiceDisconnected(name: ComponentName) { + context.longToast("Bridge service disconnected") + Thread.sleep(1000) + exitProcess(0) + } +} 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,58 @@ +package me.rhunk.snapenhance.config + +open class ConfigAccessor( + private val configMap: MutableMap<ConfigProperty, Any?> +) { + fun bool(key: ConfigProperty): Boolean { + return get(key) as Boolean + } + + fun int(key: ConfigProperty): Int { + return get(key) as Int + } + + fun string(key: ConfigProperty): String { + return get(key) as String + } + + fun double(key: ConfigProperty): Double { + return get(key) as Double + } + + fun float(key: ConfigProperty): Float { + return get(key) as Float + } + + fun long(key: ConfigProperty): Long { + return get(key) as Long + } + + fun short(key: ConfigProperty): Short { + return get(key) as Short + } + + fun byte(key: ConfigProperty): Byte { + return get(key) as Byte + } + + fun char(key: ConfigProperty): Char { + return get(key) as Char + } + + @Suppress("UNCHECKED_CAST") + fun <T> list(key: ConfigProperty): List<T> { + return get(key) as List<T> + } + + fun get(key: ConfigProperty): Any? { + return configMap[key] + } + + fun set(key: ConfigProperty, value: Any?) { + configMap[key] = value + } + + fun entries(): Set<Map.Entry<ConfigProperty, Any?>> { + return configMap.entries + } +}+ \ 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 @@ -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 @@ -0,0 +1,181 @@ +package me.rhunk.snapenhance.config + +import android.os.Environment +import java.io.File + +enum class ConfigProperty( + val nameKey: String, + val descriptionKey: String, + val category: ConfigCategory, + val defaultValue: Any +) { + SAVE_FOLDER( + "save_folder", "description.save_folder", ConfigCategory.GENERAL, + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath + "/Snapchat", + "SnapEnhance" + ).absolutePath + ), + + PREVENT_READ_RECEIPTS( + "prevent_read_receipts", + "description.prevent_read_receipts", + ConfigCategory.SPY, + false + ), + HIDE_BITMOJI_PRESENCE( + "hide_bitmoji_presence", + "description.hide_bitmoji_presence", + ConfigCategory.SPY, + false + ), + SHOW_MESSAGE_CONTENT( + "show_message_content", + "description.show_message_content", + ConfigCategory.SPY, + false + ), + MESSAGE_LOGGER("message_logger", "description.message_logger", ConfigCategory.SPY, false), + + MEDIA_DOWNLOADER_FEATURE( + "media_downloader_feature", + "description.media_downloader_feature", + ConfigCategory.MEDIA_DOWNLOADER, + true + ), + DOWNLOAD_STORIES( + "download_stories", + "description.download_stories", + ConfigCategory.MEDIA_DOWNLOADER, + false + ), + DOWNLOAD_PUBLIC_STORIES( + "download_public_stories", + "description.download_public_stories", + ConfigCategory.MEDIA_DOWNLOADER, + false + ), + DOWNLOAD_SPOTLIGHT( + "download_spotlight", + "description.download_spotlight", + ConfigCategory.MEDIA_DOWNLOADER, + false + ), + OVERLAY_MERGE( + "overlay_merge", + "description.overlay_merge", + ConfigCategory.MEDIA_DOWNLOADER, + true + ), + DOWNLOAD_INCHAT_SNAPS( + "download_inchat_snaps", + "description.download_inchat_snaps", + ConfigCategory.MEDIA_DOWNLOADER, + true + ), + + DISABLE_METRICS("disable_metrics", "description.disable_metrics", ConfigCategory.PRIVACY, true), + PREVENT_SCREENSHOTS( + "prevent_screenshots", + "description.prevent_screenshots", + ConfigCategory.PRIVACY, + true + ), + PREVENT_STATUS_NOTIFICATIONS( + "prevent_status_notifications", + "description.prevent_status_notifications", + ConfigCategory.PRIVACY, + true + ), + ANONYMOUS_STORY_VIEW( + "anonymous_story_view", + "description.anonymous_story_view", + ConfigCategory.PRIVACY, + false + ), + HIDE_TYPING_NOTIFICATION( + "hide_typing_notification", + "description.hide_typing_notification", + ConfigCategory.PRIVACY, + false + ), + + MENU_SLOT_ID("menu_slot_id", "description.menu_slot_id", ConfigCategory.UI, 1), + MESSAGE_PREVIEW_LENGTH( + "message_preview_length", + "description.message_preview_length", + ConfigCategory.UI, + 20 + ), + + AUTO_SAVE("auto_save", "description.auto_save", ConfigCategory.EXTRAS, false), + /*EXTERNAL_MEDIA_AS_SNAP( + "external_media_as_snap", + "description.external_media_as_snap", + ConfigCategory.EXTRAS, + false + ), + CONVERSATION_EXPORT( + "conversation_export", + "description.conversation_export", + ConfigCategory.EXTRAS, + false + ),*/ + SNAPCHAT_PLUS("snapchat_plus", "description.snapchat_plus", ConfigCategory.EXTRAS, false), + + REMOVE_VOICE_RECORD_BUTTON( + "remove_voice_record_button", + "description.remove_voice_record_button", + ConfigCategory.TWEAKS, + false + ), + REMOVE_STICKERS_BUTTON( + "remove_stickers_button", + "description.remove_stickers_button", + ConfigCategory.TWEAKS, + false + ), + REMOVE_COGNAC_BUTTON( + "remove_cognac_button", + "description.remove_cognac_button", + ConfigCategory.TWEAKS, + false + ), + REMOVE_CALLBUTTONS( + "remove_callbuttons", + "description.remove_callbuttons", + ConfigCategory.TWEAKS, + false + ), + LONG_SNAP_SENDING( + "long_snap_sending", + "description.long_snap_sending", + ConfigCategory.TWEAKS, + false + ), + BLOCK_ADS("block_ads", "description.block_ads", ConfigCategory.TWEAKS, false), + STREAKEXPIRATIONINFO( + "streakexpirationinfo", + "description.streakexpirationinfo", + ConfigCategory.TWEAKS, + false + ), + NEW_MAP_UI("new_map_ui", "description.new_map_ui", ConfigCategory.TWEAKS, false), + + USE_DOWNLOAD_MANAGER( + "use_download_manager", + "description.use_download_manager", + ConfigCategory.EXPERIMENTS, + false + ); + + companion object { + fun fromNameKey(nameKey: String): ConfigProperty? { + return values().find { it.nameKey == nameKey } + } + + fun sortedByCategory(): List<ConfigProperty> { + return values().sortedBy { it.category.ordinal } + } + } +}+ \ 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 @@ -0,0 +1,50 @@ +package me.rhunk.snapenhance.data + +enum class FileType( + val fileExtension: String? = null, + val isVideo: Boolean = false, + val isImage: Boolean = false, + val isAudio: Boolean = false +) { + GIF("gif", false, false, false), + PNG("png", false, true, false), + MP4("mp4", true, false, false), + MP3("mp3", false, false, true), + JPG("jpg", false, true, false), + ZIP("zip", false, false, false), + WEBP("webp", false, true, false), + UNKNOWN("dat", false, false, false); + + companion object { + private val fileSignatures = HashMap<String, FileType>() + + init { + fileSignatures["52494646"] = WEBP + fileSignatures["504b0304"] = ZIP + fileSignatures["89504e47"] = PNG + fileSignatures["00000020"] = MP4 + fileSignatures["00000018"] = MP4 + fileSignatures["0000001c"] = MP4 + fileSignatures["ffd8ffe0"] = JPG + } + + fun fromString(string: String?): FileType { + return values().firstOrNull { it.fileExtension.equals(string, ignoreCase = true) } ?: UNKNOWN + } + + private fun bytesToHex(bytes: ByteArray): String { + val result = StringBuilder() + for (b in bytes) { + result.append(String.format("%02x", b)) + } + return result.toString() + } + + fun fromByteArray(array: ByteArray): FileType { + val headerBytes = ByteArray(16) + System.arraycopy(array, 0, headerBytes, 0, 16) + val hex = bytesToHex(headerBytes) + return fileSignatures.entries.firstOrNull { hex.startsWith(it.key) }?.value ?: UNKNOWN + } + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/data/SnapClassCache.kt 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 @@ -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 @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.data.wrapper + +import de.robv.android.xposed.XposedHelpers + +abstract class AbstractWrapper( + protected var instance: Any +) { + fun instance() = instance + + override fun hashCode(): Int { + return instance.hashCode() + } + + override fun toString(): String { + return instance.toString() + } + + + fun <T : Enum<*>> getEnumValue(fieldName: String, defaultValue: T): T { + val mContentType = XposedHelpers.getObjectField(instance, fieldName) as Enum<*> + return java.lang.Enum.valueOf(defaultValue::class.java, mContentType.name) as T + } + + @Suppress("UNCHECKED_CAST") + fun setEnumValue(fieldName: String, value: Enum<*>) { + val type = instance.javaClass.fields.find { it.name == fieldName }?.type as Class<out Enum<*>> + XposedHelpers.setObjectField(instance, fieldName, java.lang.Enum.valueOf(type, value.name)) + } +}+ \ 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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,16 @@ +package me.rhunk.snapenhance.data.wrapper.impl + +import me.rhunk.snapenhance.data.PlayableSnapState +import me.rhunk.snapenhance.data.wrapper.AbstractWrapper +import me.rhunk.snapenhance.util.getObjectField + +class MessageMetadata(obj: Any) : AbstractWrapper(obj){ + val createdAt: Long get() = instance.getObjectField("mCreatedAt") as Long + val readAt: Long get() = instance.getObjectField("mReadAt") as Long + var playableSnapState: PlayableSnapState + get() = getEnumValue("mPlayableSnapState", PlayableSnapState.PLAYABLE) + set(value) { + setEnumValue("mPlayableSnapState", value) + } + val savedBy: List<SnapUUID> = (instance.getObjectField("mSavedBy") as List<*>).map { SnapUUID(it!!) } +}+ \ 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,37 @@ +package me.rhunk.snapenhance.data.wrapper.impl.media.opera + +import me.rhunk.snapenhance.data.wrapper.AbstractWrapper +import me.rhunk.snapenhance.util.ReflectionHelper +import me.rhunk.snapenhance.util.getObjectField +import java.lang.reflect.Field +import java.util.concurrent.ConcurrentHashMap + +@Suppress("UNCHECKED_CAST") +class ParamMap(obj: Any) : AbstractWrapper(obj) { + private val paramMapField: Field by lazy { + ReflectionHelper.searchFieldTypeInSuperClasses( + instance.javaClass, + ConcurrentHashMap::class.java + )!! + } + + private val concurrentHashMap: ConcurrentHashMap<Any, Any> + get() = instance.getObjectField(paramMapField.name) as ConcurrentHashMap<Any, Any> + + operator fun get(key: String): Any? { + return concurrentHashMap.keys.firstOrNull{ k: Any -> k.toString() == key }?.let { concurrentHashMap[it] } + } + + fun put(key: String, value: Any) { + val keyObject = concurrentHashMap.keys.firstOrNull { k: Any -> k.toString() == key } ?: key + concurrentHashMap[keyObject] = value + } + + fun containsKey(key: String): Boolean { + return concurrentHashMap.keys.any { k: Any -> k.toString() == key } + } + + override fun toString(): String { + return concurrentHashMap.toString() + } +} diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/database/DatabaseAccess.kt 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 <T> safeDatabaseOperation( + database: SQLiteDatabase, + query: (SQLiteDatabase) -> T? + ): T? { + synchronized(databaseLock) { + return runCatching { + query(database) + }.onFailure { + Logger.xposedLog("Database operation failed", it) + }.getOrNull() + } + } + + private fun <T : DatabaseObject> readDatabaseObject( + obj: T, + database: SQLiteDatabase, + table: String, + where: String, + args: Array<String> + ): T? { + val cursor = database.rawQuery("SELECT * FROM $table WHERE $where", args) + if (!cursor.moveToFirst()) { + cursor.close() + return null + } + try { + obj.write(cursor) + } catch (e: Throwable) { + Logger.xposedLog(e) + } + cursor.close() + return obj + } + + fun getFriendFeedInfoByUserId(userId: String): FriendFeedInfo? { + return safeDatabaseOperation(openMain()) { database -> + readDatabaseObject( + FriendFeedInfo(), + database, + "FriendsFeedView", + "friendUserId = ?", + arrayOf(userId) + ) + } + } + + fun getFriendFeedInfoByConversationId(conversationId: String): FriendFeedInfo? { + return safeDatabaseOperation(openMain()) { + readDatabaseObject( + FriendFeedInfo(), + it, + "FriendsFeedView", + "key = ?", + arrayOf(conversationId) + ) + } + } + + fun getFriendInfo(userId: String): FriendInfo? { + return safeDatabaseOperation(openMain()) { + readDatabaseObject( + FriendInfo(), + it, + "FriendWithUsername", + "userId = ?", + arrayOf(userId) + ) + } + } + + fun getConversationMessageFromId(clientMessageId: Long): ConversationMessage? { + return safeDatabaseOperation(openArroyo()) { + readDatabaseObject( + ConversationMessage(), + it, + "conversation_message", + "client_message_id = ?", + arrayOf(clientMessageId.toString()) + ) + } + } + + fun getDMConversationIdFromUserId(userId: String): UserConversationLink? { + return safeDatabaseOperation(openArroyo()) { + readDatabaseObject( + UserConversationLink(), + it, + "user_conversation", + "user_id = ? AND conversation_type = 0", + arrayOf(userId) + ) + } + } + + fun getStoryEntryFromId(storyId: String): StoryEntry? { + return safeDatabaseOperation(openMain()) { + readDatabaseObject(StoryEntry(), it, "Story", "storyId = ?", arrayOf(storyId)) + } + } + + fun getConversationParticipants(conversationId: String): List<String>? { + return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase -> + val cursor = arroyoDatabase.rawQuery( + "SELECT * FROM user_conversation WHERE client_conversation_id = ?", + arrayOf(conversationId) + ) + if (!cursor.moveToFirst()) { + cursor.close() + return@safeDatabaseOperation emptyList() + } + val participants = mutableListOf<String>() + do { + participants.add(cursor.getString(cursor.getColumnIndex("user_id"))) + } while (cursor.moveToNext()) + cursor.close() + participants + } + } + + fun getMyUserId(): String? { + return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase -> + val cursor = arroyoDatabase.rawQuery(buildString { + append("SELECT * FROM required_values WHERE key = 'USERID'") + }, null) + + if (!cursor.moveToFirst()) { + cursor.close() + return@safeDatabaseOperation null + } + + val userId = cursor.getString(cursor.getColumnIndex("value")) + cursor.close() + userId + } + } + + fun getMessagesFromConversationId( + conversationId: String, + limit: Int + ): List<ConversationMessage>? { + return safeDatabaseOperation(openArroyo()) { arroyoDatabase: SQLiteDatabase -> + val cursor = arroyoDatabase.rawQuery( + "SELECT * FROM conversation_message WHERE client_conversation_id = ? ORDER BY creation_timestamp DESC LIMIT ?", + arrayOf(conversationId, limit.toString()) + ) + if (!cursor.moveToFirst()) { + cursor.close() + return@safeDatabaseOperation emptyList() + } + val messages = mutableListOf<ConversationMessage>() + do { + val message = ConversationMessage() + message.write(cursor) + messages.add(message) + } while (cursor.moveToNext()) + cursor.close() + messages + } + } +}+ \ 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,62 @@ +package me.rhunk.snapenhance.event + +import me.rhunk.snapenhance.ModContext +import kotlin.reflect.KClass + +abstract class Event { + lateinit var context: ModContext +} + +interface IListener<T> { + fun handle(event: T) +} + +class EventBus( + private val context: ModContext +) { + private val subscribers = mutableMapOf<KClass<out Event>, MutableList<IListener<out Event>>>() + + fun <T : Event> subscribe(event: KClass<T>, listener: IListener<T>) { + if (!subscribers.containsKey(event)) { + subscribers[event] = mutableListOf() + } + subscribers[event]!!.add(listener) + } + + fun <T : Event> subscribe(event: KClass<T>, listener: (T) -> Unit) { + subscribe(event, object : IListener<T> { + override fun handle(event: T) { + listener(event) + } + }) + } + + fun <T : Event> unsubscribe(event: KClass<T>, listener: IListener<T>) { + if (!subscribers.containsKey(event)) { + return + } + subscribers[event]!!.remove(listener) + } + + fun <T : Event> post(event: T) { + if (!subscribers.containsKey(event::class)) { + return + } + + event.context = context + + subscribers[event::class]!!.forEach { listener -> + @Suppress("UNCHECKED_CAST") + try { + (listener as IListener<T>).handle(event) + } catch (t: Throwable) { + println("Error while handling event ${event::class.simpleName} by ${listener::class.simpleName}") + t.printStackTrace() + } + } + } + + fun clear() { + subscribers.clear() + } +}+ \ 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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,67 @@ +package me.rhunk.snapenhance.features.impl + +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.util.setObjectField +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import java.util.concurrent.atomic.AtomicReference + +class ConfigEnumKeys : Feature("Config enum keys", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private fun hookAllEnums(enumClass: Class<*>, callback: (String, AtomicReference<Any>) -> Unit) { + //Enum(String, int, ?) + //or Enum(?) + val enumDataClass = enumClass.constructors[0].parameterTypes.first { clazz: Class<*> -> clazz != String::class.java && !clazz.isPrimitive } + + //get the field which contains the enum data class + val enumDataField = enumClass.declaredFields.first { field: Field -> field.type == enumDataClass } + + //get the field value of the enum data class (the first field of the class with the desc Object) + val objectDataField = enumDataField.type.fields.first { field: Field -> + field.type == Any::class.java && Modifier.isPublic( + field.modifiers + ) && Modifier.isFinal(field.modifiers) + } + + enumClass.enumConstants.forEach { enum -> + enumDataField.get(enum)?.let { enumData -> + val key = objectDataField.get(enumData)!!.toString() + val value = AtomicReference(objectDataField.get(enumData)) + callback(key, value) + enumData.setObjectField(objectDataField.name, value.get()) + } + } + } + + override fun onActivityCreate() { + if (context.config.bool(ConfigProperty.NEW_MAP_UI)) { + hookAllEnums(context.mappings.getMappedClass("enums", "PLUS")) { key, atomicValue -> + if (key == "REDUCE_MY_PROFILE_UI_COMPLEXITY") atomicValue.set(true) + } + } + + if (context.config.bool(ConfigProperty.LONG_SNAP_SENDING)) { + hookAllEnums(context.mappings.getMappedClass("enums", "ARROYO")) { key, atomicValue -> + if (key == "ENABLE_LONG_SNAP_SENDING") atomicValue.set(true) + } + } + + if (context.config.bool(ConfigProperty.STREAKEXPIRATIONINFO)) { + hookAllEnums(context.mappings.getMappedClass("enums", "FRIENDS_FEED")) { key, atomicValue -> + if (key == "STREAK_EXPIRATION_INFO") atomicValue.set(true) + } + } + + if (context.config.bool(ConfigProperty.BLOCK_ADS)) { + hookAllEnums(context.mappings.getMappedClass("enums", "SNAPADS")) { key, atomicValue -> + if (key == "BYPASS_AD_FEATURE_GATE") { + atomicValue.set(true) + } + if (key == "CUSTOM_AD_SERVER_URL" || key == "CUSTOM_AD_INIT_SERVER_URL" || key == "CUSTOM_AD_TRACKER_URL") { + atomicValue.set("http://127.0.0.1") + } + } + } + } +}+ \ 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 @@ -0,0 +1,71 @@ +package me.rhunk.snapenhance.features.impl + +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker + +class Messaging : Feature("Messaging", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC or FeatureLoadParams.INIT_ASYNC or FeatureLoadParams.INIT_SYNC) { + lateinit var conversationManager: Any + + var lastOpenedConversationUUID: SnapUUID? = null + var lastFetchConversationUserUUID: SnapUUID? = null + var lastFetchConversationUUID: SnapUUID? = null + var lastFocusedMessageId: Long = -1 + + override fun init() { + Hooker.hookConstructor(context.classCache.conversationManager, HookStage.BEFORE) { + conversationManager = it.thisObject() + } + } + + override fun onActivityCreate() { + with(context.classCache.conversationManager) { + Hooker.hook(this, "enterConversation", HookStage.BEFORE) { + lastOpenedConversationUUID = SnapUUID(it.arg(0)) + } + + Hooker.hook(this, "getOneOnOneConversationIds", HookStage.BEFORE) { param -> + val conversationIds: List<Any> = param.arg(0) + if (conversationIds.isNotEmpty()) { + lastFetchConversationUserUUID = SnapUUID(conversationIds[0]) + } + } + + Hooker.hook(this, "exitConversation", HookStage.BEFORE) { + lastOpenedConversationUUID = null + } + + Hooker.hook(this, "fetchConversation", HookStage.BEFORE) { + lastFetchConversationUUID = SnapUUID(it.arg(0)) + } + } + + } + + override fun asyncInit() { + arrayOf("activate", "deactivate", "processTypingActivity").forEach { hook -> + Hooker.hook(context.classCache.presenceSession, hook, HookStage.BEFORE, { context.config.bool(ConfigProperty.HIDE_BITMOJI_PRESENCE) }) { + it.setResult(null) + } + } + + //get last opened snap for media downloader + Hooker.hook(context.classCache.snapManager, "onSnapInteraction", HookStage.BEFORE) { param -> + lastOpenedConversationUUID = SnapUUID(param.arg(1)) + lastFocusedMessageId = param.arg(2) + } + + Hooker.hook(context.classCache.conversationManager, "fetchMessage", HookStage.BEFORE) { param -> + lastFetchConversationUserUUID = SnapUUID((param.arg(0) as Any)) + lastFocusedMessageId = param.arg(1) + } + + Hooker.hook(context.classCache.conversationManager, "sendTypingNotification", HookStage.BEFORE, + {context.config.bool(ConfigProperty.HIDE_TYPING_NOTIFICATION)}) { + it.setResult(null) + } + } +}+ \ 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 @@ -0,0 +1,430 @@ +package me.rhunk.snapenhance.features.impl.downloader + +import android.app.AlertDialog +import android.content.DialogInterface +import android.graphics.Bitmap +import android.media.MediaScannerConnection +import android.net.Uri +import android.widget.ImageView +import com.arthenica.ffmpegkit.FFmpegKit +import de.robv.android.xposed.XposedBridge +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.Constants.ARROYO_URL_KEY_PROTO_PATH +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.Logger.xposedLog +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.data.FileType +import me.rhunk.snapenhance.data.wrapper.impl.media.MediaInfo +import me.rhunk.snapenhance.data.wrapper.impl.media.opera.Layer +import me.rhunk.snapenhance.data.wrapper.impl.media.opera.ParamMap +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.spy.MessageLogger +import me.rhunk.snapenhance.hook.HookAdapter +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.util.EncryptionUtils +import me.rhunk.snapenhance.util.PreviewUtils +import me.rhunk.snapenhance.util.download.CdnDownloader +import me.rhunk.snapenhance.util.getObjectField +import me.rhunk.snapenhance.util.protobuf.ProtoReader +import java.io.* +import java.net.HttpURLConnection +import java.net.URL +import java.nio.file.Paths +import java.util.* +import java.util.concurrent.atomic.AtomicReference +import java.util.zip.ZipInputStream +import javax.crypto.Cipher +import javax.crypto.CipherInputStream +import kotlin.io.path.inputStream + +enum class MediaType { + ORIGINAL, OVERLAY +} + +class MediaDownloader : Feature("MediaDownloader", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + private var lastSeenMediaInfoMap: MutableMap<MediaType, MediaInfo>? = null + private var lastSeenMapParams: ParamMap? = null + private var isFFmpegPresent: Boolean? = null + + private fun canMergeOverlay(): Boolean { + if (!context.config.bool(ConfigProperty.OVERLAY_MERGE)) return false + if (isFFmpegPresent != null) { + return isFFmpegPresent!! + } + //check if ffmpeg is correctly installed + isFFmpegPresent = runCatching { FFmpegKit.execute("-version") }.isSuccess + return isFFmpegPresent!! + } + + private fun createNewFilePath(hash: Int, author: String, fileType: FileType): String? { + val hexHash = Integer.toHexString(hash) + return author + "/" + hexHash + "." + fileType.fileExtension + } + + private fun downloadFile(outputFile: File, content: ByteArray): Boolean { + val onDownloadComplete = { + context.shortToast( + "Saved to " + outputFile.absolutePath.replace(context.config.string(ConfigProperty.SAVE_FOLDER), "") + .substring(1) + ) + } + if (!context.config.bool(ConfigProperty.USE_DOWNLOAD_MANAGER)) { + try { + val fos = FileOutputStream(outputFile) + fos.write(content) + fos.close() + MediaScannerConnection.scanFile( + context.androidContext, + arrayOf(outputFile.absolutePath), + null, + null + ) + onDownloadComplete() + } catch (e: Throwable) { + Logger.xposedLog(e) + context.longToast("Failed to save file: " + e.message) + return false + } + return true + } + context.downloadServer.startFileDownload(outputFile, content) { result -> + if (result) { + onDownloadComplete() + return@startFileDownload + } + context.longToast("Failed to save file. Check logs for more info.") + } + return true + } + + + private fun mergeOverlay(original: ByteArray, overlay: ByteArray, isPreviewMode: Boolean): ByteArray? { + context.longToast("Merging current media with overlay. This may take a while.") + val originalFileType = FileType.fromByteArray(original) + val overlayFileType = FileType.fromByteArray(overlay) + //merge files + val mergedFile = File.createTempFile("merged", "." + originalFileType.fileExtension) + val tempVideoFile = File.createTempFile("original", "." + originalFileType.fileExtension).also { + with(FileOutputStream(it)) { + write(original) + close() + } + } + val tempOverlayFile = File.createTempFile("overlay", "." + overlayFileType.fileExtension).also { + with(FileOutputStream(it)) { + write(overlay) + close() + } + } + + //TODO: improve ffmpeg speed + val fFmpegSession = FFmpegKit.execute( + "-y -i " + + tempVideoFile.absolutePath + + " -i " + + tempOverlayFile.absolutePath + + " -filter_complex \"[0]scale2ref[img][vid];[img]setsar=1[img];[vid]nullsink; [img][1]overlay=(W-w)/2:(H-h)/2,scale=2*trunc(iw*sar/2):2*trunc(ih/2)\" -c:v libx264 -q:v 13 -c:a copy " + + " -threads 6 ${(if (isPreviewMode) "-frames:v 1" else "")} " + + mergedFile.absolutePath + ) + tempVideoFile.delete() + tempOverlayFile.delete() + if (fFmpegSession.returnCode.value != 0) { + mergedFile.delete() + context.longToast("Failed to merge video and overlay. See logs for more details.") + Logger.xposedLog(fFmpegSession.output) + return null + } + val mergedFileData: ByteArray = FileInputStream(mergedFile).readBytes() + mergedFile.delete() + return mergedFileData + } + + private fun queryMediaData(mediaInfo: MediaInfo): ByteArray { + val mediaUri = Uri.parse(mediaInfo.uri) + val mediaInputStream = AtomicReference<InputStream>() + if (mediaUri.scheme == "file") { + mediaInputStream.set(Paths.get(mediaUri.path).inputStream()) + } else { + val url = URL(mediaUri.toString()) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.setRequestProperty("User-Agent", Constants.USER_AGENT) + connection.connect() + mediaInputStream.set(connection.inputStream) + } + mediaInfo.encryption?.let { encryption -> + mediaInputStream.set(CipherInputStream(mediaInputStream.get(), encryption.newCipher(Cipher.DECRYPT_MODE))) + } + return mediaInputStream.get().readBytes() + } + + private fun createNeededDirectories(file: File): File { + val directory = file.parentFile ?: return file + if (!directory.exists()) { + directory.mkdirs() + } + return file + } + + private fun isFileExists(hash: Int, author: String, fileType: FileType): Boolean { + val fileName: String = createNewFilePath(hash, author, fileType) ?: return false + val outputFile: File = + createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName)) + return outputFile.exists() + } + + + /* + * Download the last seen media + */ + fun downloadLastOperaMediaAsync() { + if (lastSeenMapParams == null || lastSeenMediaInfoMap == null) return + context.executeAsync { + handleOperaMedia( + lastSeenMapParams!!, + lastSeenMediaInfoMap!!, true + ) + } + } + + private fun downloadOperaMedia(mediaInfoMap: Map<MediaType, MediaInfo>, author: String) { + if (mediaInfoMap.isEmpty()) return + val originalMediaInfo = mediaInfoMap[MediaType.ORIGINAL]!! + if (mediaInfoMap.containsKey(MediaType.OVERLAY)) { + context.shortToast("Downloading split snap") + } + var mediaContent: ByteArray? = queryMediaData(originalMediaInfo) + val hash = Arrays.hashCode(mediaContent) + if (mediaInfoMap.containsKey(MediaType.OVERLAY)) { + //prevent converting the same media twice + if (isFileExists(hash, author, FileType.fromByteArray(mediaContent!!))) { + context.shortToast("Media already exists") + return + } + val overlayMediaInfo = mediaInfoMap[MediaType.OVERLAY]!! + val overlayContent: ByteArray = queryMediaData(overlayMediaInfo) + mediaContent = mergeOverlay(mediaContent, overlayContent, false) + } + val fileType = FileType.fromByteArray(mediaContent!!) + downloadMediaContent(mediaContent, hash, author, fileType) + } + + private fun downloadMediaContent( + data: ByteArray, + hash: Int, + messageAuthor: String, + fileType: FileType + ): Boolean { + val fileName: String = createNewFilePath(hash, messageAuthor, fileType) ?: return false + val outputFile: File = createNeededDirectories(File(context.config.string(ConfigProperty.SAVE_FOLDER), fileName)) + if (outputFile.exists()) { + context.shortToast("Media already exists") + return false + } + return downloadFile(outputFile, data) + } + + /** + * Handles the media from the opera viewer + * + * @param paramMap the parameters from the opera viewer + * @param mediaInfoMap the media info map + * @param forceDownload if the media should be downloaded + */ + private fun handleOperaMedia( + paramMap: ParamMap, + mediaInfoMap: Map<MediaType, MediaInfo>, + forceDownload: Boolean + ) { + //messages + if (paramMap.containsKey("MESSAGE_ID")) { + val id = paramMap["MESSAGE_ID"].toString() + val messageId = id.substring(id.lastIndexOf(":") + 1).toLong() + val senderId: String = context.database.getConversationMessageFromId(messageId)!!.sender_id!! + val author = context.database.getFriendInfo(senderId)!!.usernameForSorting!! + downloadOperaMedia(mediaInfoMap, author) + return + } + + //private stories + val playlistV2Group = + if (paramMap.containsKey("PLAYLIST_V2_GROUP")) paramMap["PLAYLIST_V2_GROUP"].toString() else null + if (playlistV2Group != null && + playlistV2Group.contains("storyUserId=") && + (forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_STORIES)) + ) { + val storyIdStartIndex = playlistV2Group.indexOf("storyUserId=") + 12 + val storyUserId = playlistV2Group.substring(storyIdStartIndex, playlistV2Group.indexOf(",", storyIdStartIndex)) + val author = context.database.getFriendInfo(storyUserId) + downloadOperaMedia(mediaInfoMap, author!!.usernameForSorting!!) + return + } + val snapSource = paramMap["SNAP_SOURCE"].toString() + + //public stories + if (snapSource == "PUBLIC_USER" && (forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_PUBLIC_STORIES))) { + val userDisplayName = (if (paramMap.containsKey("USER_DISPLAY_NAME")) paramMap["USER_DISPLAY_NAME"].toString() else "").replace( + "[^\\x00-\\x7F]".toRegex(), + "") + downloadOperaMedia(mediaInfoMap, "Public-Stories/$userDisplayName") + } + + //spotlight + if (snapSource == "SINGLE_SNAP_STORY" && (forceDownload || context.config.bool(ConfigProperty.DOWNLOAD_SPOTLIGHT))) { + downloadOperaMedia(mediaInfoMap, "Spotlight") + } + } + + override fun asyncOnActivityCreate() { + val operaViewerControllerClass: Class<*> = context.mappings.getMappedClass("OperaPageViewController", "Class") + + val onOperaViewStateCallback: (HookAdapter) -> Unit = onOperaViewStateCallback@{ param -> + val viewState = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "viewStateField")).toString() + if (viewState != "FULLY_DISPLAYED") { + return@onOperaViewStateCallback + } + val operaLayerList = (param.thisObject() as Any).getObjectField(context.mappings.getMappedValue("OperaPageViewController", "layerListField")) as ArrayList<*> + val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap + + if (!mediaParamMap.containsKey("image_media_info") && !mediaParamMap.containsKey("video_media_info_list")) + return@onOperaViewStateCallback + + val mediaInfoMap = mutableMapOf<MediaType, MediaInfo>() + val isVideo = mediaParamMap.containsKey("video_media_info_list") + mediaInfoMap[MediaType.ORIGINAL] = MediaInfo( + (if (isVideo) mediaParamMap["video_media_info_list"] else mediaParamMap["image_media_info"])!! + ) + if (canMergeOverlay() && mediaParamMap.containsKey("overlay_image_media_info")) { + mediaInfoMap[MediaType.OVERLAY] = + MediaInfo(mediaParamMap["overlay_image_media_info"]!!) + } + lastSeenMapParams = mediaParamMap + lastSeenMediaInfoMap = mediaInfoMap + if (!context.config.bool(ConfigProperty.MEDIA_DOWNLOADER_FEATURE)) return@onOperaViewStateCallback + + context.executeAsync { + try { + handleOperaMedia(mediaParamMap, mediaInfoMap, false) + } catch (e: Throwable) { + Logger.xposedLog(e) + context.longToast(e.message!!) + } + } + } + + arrayOf("onDisplayStateChange", "onDisplayStateChange2").forEach { methodName -> + Hooker.hook( + operaViewerControllerClass, + context.mappings.getMappedValue("OperaPageViewController", methodName), + HookStage.AFTER, onOperaViewStateCallback + ) + } + } + + /** + * Called when a message is focused in chat + */ + //TODO: use snapchat classes instead of database (when content is deleted) + fun onMessageActionMenu(isPreviewMode: Boolean) { + //check if the message was focused in a conversation + val messaging = context.feature(Messaging::class) + if (messaging.lastOpenedConversationUUID == null) return + val message = context.database.getConversationMessageFromId(messaging.lastFocusedMessageId) ?: return + + //get the message author + val messageAuthor: String = context.database.getFriendInfo(message.sender_id!!)!!.usernameForSorting!! + + //check if the messageId + val contentType: ContentType = ContentType.fromId(message.content_type) + if (context.feature(MessageLogger::class).isMessageRemoved(message.client_message_id.toLong())) { + context.shortToast("Preview/Download are not yet available for deleted messages") + return + } + if (contentType != ContentType.NOTE && + contentType != ContentType.SNAP && + contentType != ContentType.EXTERNAL_MEDIA) { + context.shortToast("Unsupported content type $contentType") + return + } + val messageReader = ProtoReader(message.message_content!!) + val urlKey: String = messageReader.getString(*ARROYO_URL_KEY_PROTO_PATH)!! + + //download the message content + try { + context.shortToast("Retriving message media") + var inputStream: InputStream = CdnDownloader.downloadWithDefaultEndpoints(urlKey) ?: return + inputStream = EncryptionUtils.decryptInputStreamFromArroyo( + inputStream, + contentType, + messageReader + ) + + var mediaData: ByteArray = inputStream.readBytes() + var fileType = FileType.fromByteArray(mediaData) + val isZipFile = fileType == FileType.ZIP + + //videos with overlay are packed in a zip file + //there are 2 files in the zip file, the video (webm) and the overlay (png) + if (isZipFile) { + var videoData: ByteArray? = null + var overlayData: ByteArray? = null + val zipInputStream = ZipInputStream(ByteArrayInputStream(mediaData)) + while (zipInputStream.nextEntry != null) { + val zipEntryData: ByteArray = zipInputStream.readBytes() + val entryFileType = FileType.fromByteArray(zipEntryData) + if (entryFileType.isVideo) { + videoData = zipEntryData + } else if (entryFileType.isImage) { + overlayData = zipEntryData + } + } + if (videoData == null || overlayData == null) { + Logger.xposedLog("Invalid data in zip file") + return + } + val mergedVideo = mergeOverlay(videoData, overlayData, isPreviewMode) + val videoFileType = FileType.fromByteArray(videoData) + if (!isPreviewMode) { + downloadMediaContent( + mergedVideo!!, + Arrays.hashCode(videoData), + messageAuthor, + videoFileType + ) + return + } + mediaData = mergedVideo!! + fileType = videoFileType + } + if (isPreviewMode) { + runCatching { + val bitmap: Bitmap = PreviewUtils.createPreview(mediaData, fileType.isVideo)!! + val builder = AlertDialog.Builder(context.mainActivity) + builder.setTitle("Preview") + val imageView = ImageView(builder.context) + imageView.setImageBitmap(bitmap) + builder.setView(imageView) + builder.setPositiveButton( + "Close" + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + context.runOnUiThread { builder.show() } + }.onFailure { + context.shortToast("Failed to create preview: ${it.message}") + xposedLog(it) + } + return + } + downloadMediaContent(mediaData, mediaData.contentHashCode(), messageAuthor, fileType) + } catch (e: FileNotFoundException) { + context.shortToast("Unable to get $urlKey from cdn list. Check the logs for more info") + } catch (e: Throwable) { + context.shortToast("Failed to download " + e.message) + xposedLog(e) + } + } +}+ \ 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 @@ -0,0 +1,133 @@ +package me.rhunk.snapenhance.features.impl.extras + +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.data.wrapper.impl.Message +import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.spy.MessageLogger +import me.rhunk.snapenhance.features.impl.spy.StealthMode +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.util.CallbackBuilder +import me.rhunk.snapenhance.util.getObjectField +import java.util.concurrent.Executors + +class AutoSave : Feature("Auto Save", loadParams = FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { + private val asyncSaveExecutorService = Executors.newSingleThreadExecutor() + + private val messageLogger by lazy { context.feature(MessageLogger::class) } + private val messaging by lazy { context.feature(Messaging::class) } + + private val myUserId by lazy { context.database.getMyUserId() } + + private val fetchConversationWithMessagesCallbackClass by lazy { context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") } + private val callbackClass by lazy { context.mappings.getMappedClass("callbacks", "Callback") } + + private val updateMessageMethod by lazy { context.classCache.conversationManager.methods.first { it.name == "updateMessage" } } + private val fetchConversationWithMessagesPaginatedMethod by lazy { + context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessagesPaginated" } + } + + private fun saveMessage(conversationId: SnapUUID, message: Message) { + val messageId = message.messageDescriptor.messageId + if (messageLogger.isMessageRemoved(messageId)) return + + val callback = CallbackBuilder(callbackClass) + .override("onError") { + Logger.xposedLog("Error saving message $messageId") + }.build() + + runCatching { + updateMessageMethod.invoke( + context.feature(Messaging::class).conversationManager, + conversationId.instance(), + messageId, + context.classCache.messageUpdateEnum.enumConstants.first { it.toString() == "SAVE" }, + callback + ) + }.onFailure { + Logger.xposedLog("Error saving message $messageId", it) + } + + //delay between saves + Thread.sleep(100L) + } + + private fun canSaveMessage(message: Message): Boolean { + if (message.messageMetadata.savedBy.any { uuid -> uuid.toString() == myUserId }) return false + //only save chats + with(message.messageContent.contentType) { + if (this != ContentType.CHAT && + this != ContentType.NOTE && + this != ContentType.STICKER && + this != ContentType.EXTERNAL_MEDIA) return false + } + return true + } + + private fun canSave(): Boolean { + with(context.feature(Messaging::class)) { + if (lastOpenedConversationUUID == null || context.feature(StealthMode::class).isStealth(lastOpenedConversationUUID.toString())) return@canSave false + } + return true + } + + override fun asyncOnActivityCreate() { + //called when enter in a conversation (or when a message is sent) + Hooker.hook( + context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback"), + "onFetchConversationWithMessagesComplete", + HookStage.BEFORE, + { context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()} + ) { param -> + val conversationId = SnapUUID(param.arg<Any>(0).getObjectField("mConversationId")) + val messages = param.arg<List<Any>>(1).map { Message(it) } + messages.forEach { + if (!canSaveMessage(it)) return@forEach + asyncSaveExecutorService.submit { + saveMessage(conversationId, it) + } + } + } + + //called when a message is received + Hooker.hook( + context.mappings.getMappedClass("callbacks", "FetchMessageCallback"), + "onFetchMessageComplete", + HookStage.BEFORE, + { context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()} + ) { param -> + val message = Message(param.arg(0)) + if (!canSaveMessage(message)) return@hook + val conversationId = message.messageDescriptor.conversationId + + asyncSaveExecutorService.submit { + saveMessage(conversationId, message) + } + } + + Hooker.hook( + context.mappings.getMappedClass("callbacks", "SendMessageCallback"), + "onSuccess", + HookStage.BEFORE, + { context.config.bool(ConfigProperty.AUTO_SAVE) && canSave()} + ) { + val callback = CallbackBuilder(fetchConversationWithMessagesCallbackClass).build() + runCatching { + fetchConversationWithMessagesPaginatedMethod.invoke( + messaging.conversationManager, messaging.lastOpenedConversationUUID!!.instance(), + Long.MAX_VALUE, + 3, + callback + ) + }.onFailure { + Logger.xposedLog("failed to save message", it) + } + } + + } +}+ \ 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 @@ -0,0 +1,183 @@ +package me.rhunk.snapenhance.features.impl.extras + +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import android.graphics.Bitmap +import android.os.Bundle +import android.os.UserHandle +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.data.MediaReferenceType +import me.rhunk.snapenhance.data.wrapper.impl.Message +import me.rhunk.snapenhance.data.wrapper.impl.SnapUUID +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.util.CallbackBuilder +import me.rhunk.snapenhance.util.EncryptionUtils +import me.rhunk.snapenhance.util.PreviewUtils +import me.rhunk.snapenhance.util.download.CdnDownloader +import me.rhunk.snapenhance.util.protobuf.ProtoReader + +class Notifications : Feature("Notifications", loadParams = FeatureLoadParams.INIT_SYNC) { + private val notificationDataQueue = mutableMapOf<Long, NotificationData>() + private val cachedNotifications = mutableMapOf<String, MutableList<String>>() + + private val notifyAsUserMethod by lazy { + XposedHelpers.findMethodExact( + NotificationManager::class.java, "notifyAsUser", + String::class.java, + Int::class.javaPrimitiveType, + Notification::class.java, + UserHandle::class.java + ) + } + + private val fetchConversationWithMessagesMethod by lazy { + context.classCache.conversationManager.methods.first { it.name == "fetchConversationWithMessages"} + } + + private val notificationManager by lazy { + context.androidContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + private fun setNotificationText(notification: NotificationData, text: String) { + with(notification.notification.extras) { + putString("android.text", text) + putString("android.bigText", text) + } + } + + private fun computeNotificationText(conversationId: String): String { + val messageBuilder = StringBuilder() + cachedNotifications.computeIfAbsent(conversationId) { mutableListOf() }.forEach { + if (messageBuilder.isNotEmpty()) messageBuilder.append("\n") + messageBuilder.append(it) + } + return messageBuilder.toString() + } + + private fun fetchMessagesResult(conversationId: String, messages: List<Message>) { + val sendNotificationData = { it: NotificationData -> + XposedBridge.invokeOriginalMethod(notifyAsUserMethod, notificationManager, arrayOf( + it.tag, it.id, it.notification, it.userHandle + )) + } + + notificationDataQueue.entries.onEach { (messageId, notificationData) -> + val snapMessage = messages.firstOrNull { message -> message.orderKey == messageId } ?: return + val senderUsername = context.database.getFriendInfo(snapMessage.senderId.toString())?.displayName ?: throw Throwable("Cant find senderId of message $snapMessage") + + val contentType = snapMessage.messageContent.contentType + val contentData = snapMessage.messageContent.content + + val formatUsername: (String) -> String = { "$senderUsername: $it" } + val notificationCache = cachedNotifications.let { it.computeIfAbsent(conversationId) { mutableListOf() } } + val appendNotifications: () -> Unit = { setNotificationText(notificationData, computeNotificationText(conversationId))} + + when (contentType) { + ContentType.NOTE -> { + notificationCache.add(formatUsername("sent audio note")) + appendNotifications() + } + ContentType.CHAT -> { + ProtoReader(contentData).getString(2, 1)?.trim()?.let { + notificationCache.add(formatUsername(it)) + } + appendNotifications() + } + ContentType.SNAP -> { + //serialize the message content into a json object + val serializedMessageContent = context.gson.toJsonTree(snapMessage.messageContent.instance()).asJsonObject + val mediaReferences = serializedMessageContent["mRemoteMediaReferences"] + .asJsonArray.map { it.asJsonObject["mMediaReferences"].asJsonArray } + .flatten() + + mediaReferences.forEach { media -> + val mediaContent = media.asJsonObject["mContentObject"].asJsonArray.map { it.asByte }.toByteArray() + val mediaType = MediaReferenceType.valueOf(media.asJsonObject["mMediaType"].asString) + val urlKey = ProtoReader(mediaContent).getString(2, 2) ?: return@forEach + runCatching { + //download the media + var mediaInputStream = CdnDownloader.downloadWithDefaultEndpoints(urlKey)!! + val mediaInfo = ProtoReader(contentData).readPath(*Constants.MESSAGE_SNAP_ENCRYPTION_PROTO_PATH) ?: return@runCatching + //decrypt if necessary + if (mediaInfo.exists(Constants.ARROYO_ENCRYPTION_PROTO_INDEX)) { + mediaInputStream = EncryptionUtils.decryptInputStream(mediaInputStream, false, mediaInfo, Constants.ARROYO_ENCRYPTION_PROTO_INDEX) + } + + val mediaByteArray = mediaInputStream.readBytes() + val bitmapPreview = PreviewUtils.createPreview(mediaByteArray, mediaType == MediaReferenceType.VIDEO)!! + + val notificationBuilder = XposedHelpers.newInstance( + Notification.Builder::class.java, + context.androidContext, + notificationData.notification + ) as Notification.Builder + notificationBuilder.setLargeIcon(bitmapPreview) + notificationBuilder.style = Notification.BigPictureStyle().bigPicture(bitmapPreview).bigLargeIcon(null as Bitmap?) + + sendNotificationData(notificationData.copy(id = System.nanoTime().toInt(), notification = notificationBuilder.build())) + return@onEach + }.onFailure { + Logger.error("Failed to send preview notification", it) + } + } + } + else -> { + notificationCache.add(formatUsername("sent $contentType")) + } + } + + sendNotificationData(notificationData) + }.clear() + } + + override fun init() { + val fetchConversationWithMessagesCallback = context.mappings.getMappedClass("callbacks", "FetchConversationWithMessagesCallback") + + Hooker.hook(notifyAsUserMethod, HookStage.BEFORE, { context.config.bool(ConfigProperty.SHOW_MESSAGE_CONTENT) }) { + val notificationData = NotificationData(it.argNullable(0), it.arg(1), it.arg(2), it.arg(3)) + + if (!notificationData.notification.extras.containsKey("system_notification_extras")) { + return@hook + } + val extras: Bundle = notificationData.notification.extras.getBundle("system_notification_extras")!! + + val messageId = extras.getString("message_id")!! + val notificationType = extras.getString("notification_type")!! + val conversationId = extras.getString("conversation_id")!! + + if (!notificationType.endsWith("CHAT") && !notificationType.endsWith("SNAP")) return@hook + + val conversationManager: Any = context.feature(Messaging::class).conversationManager + notificationDataQueue[messageId.toLong()] = notificationData + + val callback = CallbackBuilder(fetchConversationWithMessagesCallback) + .override("onFetchConversationWithMessagesComplete") { param -> + val messageList = (param.arg(1) as List<Any>).map { msg -> Message(msg) } + fetchMessagesResult(conversationId, messageList) + } + .override("onError") { param -> + Logger.xposedLog("Failed to fetch message ${param.arg(0) as Any}") + }.build() + + fetchConversationWithMessagesMethod.invoke(conversationManager, SnapUUID.fromString(conversationId).instance(), callback) + it.setResult(null) + } + } + + data class NotificationData( + val tag: String?, + val id: Int, + var notification: Notification, + val userHandle: UserHandle + ) +}+ \ 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,64 @@ +package me.rhunk.snapenhance.features.impl.spy + +import com.google.gson.JsonParser +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.data.MessageState +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker +import me.rhunk.snapenhance.util.getObjectField + +class MessageLogger : Feature("MessageLogger", loadParams = FeatureLoadParams.INIT_SYNC) { + private val messageCache = mutableMapOf<Long, String>() + private val removedMessages = linkedSetOf<Long>() + + fun isMessageRemoved(messageId: Long) = removedMessages.contains(messageId) + + override fun init() { + Hooker.hookConstructor(context.classCache.message, HookStage.AFTER, { + context.config.bool(ConfigProperty.MESSAGE_LOGGER) + }) { + val message = it.thisObject<Any>() + val messageId = message.getObjectField("mDescriptor").getObjectField("mMessageId") as Long + val contentType = ContentType.valueOf(message.getObjectField("mMessageContent").getObjectField("mContentType").toString()) + val messageState = MessageState.valueOf(message.getObjectField("mState").toString()) + + if (messageState != MessageState.COMMITTED) return@hookConstructor + + if (contentType == ContentType.STATUS) { + //query the deleted message + val deletedMessage: String = if (messageCache.containsKey(messageId)) messageCache[messageId] else { + context.bridgeClient.getMessageLoggerMessage(messageId)?.toString(Charsets.UTF_8) + } ?: return@hookConstructor + + val messageJsonObject = JsonParser.parseString(deletedMessage).asJsonObject + + //if the message is a snap make it playable + if (messageJsonObject["mMessageContent"].asJsonObject["mContentType"].asString == "SNAP") { + messageJsonObject["mMetadata"].asJsonObject.addProperty("mPlayableSnapState", "PLAYABLE") + } + + //serialize all properties of messageJsonObject and put in the message object + message.javaClass.declaredFields.forEach { field -> + field.isAccessible = true + val fieldName = field.name + val fieldValue = messageJsonObject[fieldName] + if (fieldValue != null) { + field.set(message, context.gson.fromJson(fieldValue, field.type)) + } + } + + removedMessages.add(messageId) + return@hookConstructor + } + + if (!messageCache.containsKey(messageId)) { + val serializedMessage = context.gson.toJson(message) + messageCache[messageId] = serializedMessage + context.bridgeClient.addMessageLoggerMessage(messageId, serializedMessage.toByteArray(Charsets.UTF_8)) + } + } + } +}+ \ 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 @@ -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 @@ -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 @@ -0,0 +1,62 @@ +package me.rhunk.snapenhance.features.impl.spy + +import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.InputStreamReader +import java.nio.charset.StandardCharsets + + +class StealthMode : Feature("StealthMode", loadParams = FeatureLoadParams.ACTIVITY_CREATE_SYNC) { + private val stealthConversations = mutableListOf<String>() + + override fun onActivityCreate() { + readStealthFile() + } + + private fun writeStealthFile() { + val sb = StringBuilder() + for (stealthConversation in stealthConversations) { + sb.append(stealthConversation).append("\n") + } + context.bridgeClient.writeFile( + FileAccessRequest.FileType.STEALTH, + sb.toString().toByteArray(StandardCharsets.UTF_8) + ) + } + + private fun readStealthFile() { + val conversations = mutableListOf<String>() + val stealthFileData: ByteArray = context.bridgeClient.createAndReadFile(FileAccessRequest.FileType.STEALTH, ByteArray(0)) + //read conversations + with(BufferedReader(InputStreamReader( + ByteArrayInputStream(stealthFileData), + StandardCharsets.UTF_8 + ))) { + var line: String = "" + while (readLine()?.also { line = it } != null) { + conversations.add(line) + } + close() + } + stealthConversations.clear() + stealthConversations.addAll(conversations) + } + + fun setStealth(conversationId: String, stealth: Boolean) { + conversationId.hashCode().toLong().toString(16).let { + if (stealth) { + stealthConversations.add(it) + } else { + stealthConversations.remove(it) + } + } + writeStealthFile() + } + + fun isStealth(conversationId: String): Boolean { + return stealthConversations.contains(conversationId.hashCode().toLong().toString(16)) + } +} 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -0,0 +1,231 @@ +package me.rhunk.snapenhance.features.impl.ui.menus.impl + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.Button +import android.widget.CompoundButton +import android.widget.Switch +import android.widget.Toast +import de.robv.android.xposed.XposedBridge +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.data.ContentType +import me.rhunk.snapenhance.database.objects.ConversationMessage +import me.rhunk.snapenhance.database.objects.FriendInfo +import me.rhunk.snapenhance.database.objects.UserConversationLink +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.spy.StealthMode +import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu +import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme +import java.net.HttpURLConnection +import java.net.URL +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.* + +class FriendFeedInfoMenu : AbstractMenu() { + private fun getImageDrawable(url: String): Drawable { + val connection = URL(url).openConnection() as HttpURLConnection + connection.connect() + val input = connection.inputStream + return BitmapDrawable(Resources.getSystem(), BitmapFactory.decodeStream(input)) + } + + private fun formatDate(timestamp: Long): String? { + return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).format(Date(timestamp)) + } + + fun showProfileInfo(profile: FriendInfo) { + var icon: Drawable? = null + try { + if (profile.bitmojiSelfieId != null && profile.bitmojiAvatarId != null) { + icon = getImageDrawable( + "https://sdk.bitmoji.com/render/panel/" + profile.bitmojiSelfieId + .toString() + "-" + profile.bitmojiAvatarId + .toString() + "-v1.webp?transparent=1&scale=0" + ) + } + } catch (e: Throwable) { + Logger.xposedLog(e) + } + val finalIcon = icon + context.runOnUiThread { + val addedTimestamp: Long = profile.addedTimestamp.coerceAtLeast(profile.reverseAddedTimestamp) + val builder = AlertDialog.Builder(context.mainActivity) + builder.setIcon(finalIcon) + builder.setTitle(profile.displayName) + val birthday = Calendar.getInstance() + birthday[Calendar.MONTH] = (profile.birthday shr 32).toInt() - 1 + val message: String = """ + ${context.translation.get("info.username")}: ${profile.username} + ${context.translation.get("info.display_name")}: ${profile.displayName} + ${context.translation.get("info.added_date")}: ${formatDate(addedTimestamp)} + ${birthday.getDisplayName( + Calendar.MONTH, + Calendar.LONG, + context.translation.getLocale() + )?.let { + context.translation.get("info.birthday") + .replace("{month}", it) + .replace("{day}", profile.birthday.toInt().toString()) + } + } + """.trimIndent() + builder.setMessage(message) + builder.setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + builder.show() + } + } + + fun showPreview(userId: String?, conversationId: String, androidCtx: Context?) { + //query message + val messages: List<ConversationMessage>? = context.database.getMessagesFromConversationId( + conversationId, + context.config.int(ConfigProperty.MESSAGE_PREVIEW_LENGTH) + )?.reversed() + + if (messages == null || messages.isEmpty()) { + Toast.makeText(androidCtx, "No messages found", Toast.LENGTH_SHORT).show() + return + } + val participants: Map<String, FriendInfo> = context.database.getConversationParticipants(conversationId)!! + .map { context.database.getFriendInfo(it)!! } + .associateBy { it.userId!! } + + val messageBuilder = StringBuilder() + + messages.forEach{ message: ConversationMessage -> + val sender: FriendInfo? = participants[message.sender_id] + + var messageString: String = message.getMessageAsString() ?: ContentType.fromId(message.content_type).name + + if (message.content_type == ContentType.SNAP.id) { + val readTimeStamp: Long = message.read_timestamp + messageString = "\uD83D\uDFE5" //red square + if (readTimeStamp > 0) { + messageString += " \uD83D\uDC40 " //eyes + messageString += DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT + ).format(Date(readTimeStamp)) + } + } + + var displayUsername = sender?.displayName ?: "Unknown user" + + if (displayUsername.length > 12) { + displayUsername = displayUsername.substring(0, 13) + "... " + } + + messageBuilder.append(displayUsername).append(": ").append(messageString).append("\n") + } + + val targetPerson: FriendInfo? = + if (userId == null) null else participants[userId] + + targetPerson?.let { + val timeSecondDiff = ((it.streakExpirationTimestamp - System.currentTimeMillis()) / 1000 / 60).toInt() + messageBuilder.append("\n\n") + .append("\uD83D\uDD25 ") //fire emoji + .append(context.translation.get("streak_expiration").format( + timeSecondDiff / 60 / 24, + timeSecondDiff / 60 % 24, + timeSecondDiff % 60 + )) + } + + //alert dialog + val builder = AlertDialog.Builder(context.mainActivity) + builder.setTitle(context.translation.get("preview")) + builder.setMessage(messageBuilder.toString()) + builder.setPositiveButton( + "OK" + ) { dialog: DialogInterface, _: Int -> dialog.dismiss() } + targetPerson?.let { + builder.setNegativeButton(context.translation.get("profile_info")) {_, _ -> + context.executeAsync { + showProfileInfo(it) + } + } + } + builder.show() + } + + @SuppressLint("SetTextI18n", "UseSwitchCompatOrMaterialCode", "DefaultLocale") + fun inject(viewModel: View, viewConsumer: ((View) -> Unit)) { + val messaging = context.feature(Messaging::class) + var focusedConversationTargetUser: String? = null + val conversationId: String + if (messaging.lastFetchConversationUserUUID != null) { + focusedConversationTargetUser = messaging.lastFetchConversationUserUUID.toString() + val conversation: UserConversationLink = context.database.getDMConversationIdFromUserId(focusedConversationTargetUser) ?: return + conversationId = conversation.client_conversation_id!!.trim().lowercase() + } else { + conversationId = messaging.lastFetchConversationUUID.toString() + } + + //preview button + val previewButton = Button(viewModel.context) + previewButton.text = context.translation.get("preview") + applyTheme(viewModel, previewButton) + val finalFocusedConversationTargetUser = focusedConversationTargetUser + previewButton.setOnClickListener { v: View? -> + showPreview( + finalFocusedConversationTargetUser, + conversationId, + previewButton.context + ) + } + + //export conversation + /*val exportButton = Button(viewModel.context) + exportButton.setText(context.translation.get("conversation_export")) + applyTheme(viewModel, exportButton) + exportButton.setOnClickListener { event: View? -> + conversationExport.exportConversation( + SnapUUID(conversationId) + ) + }*/ + + //stealth switch + val stealthSwitch = Switch(viewModel.context) + stealthSwitch.text = context.translation.get("stealth_mode") + stealthSwitch.isChecked = context.feature(StealthMode::class).isStealth(conversationId) + applyTheme(viewModel, stealthSwitch) + stealthSwitch.setOnCheckedChangeListener { _: CompoundButton?, isChecked: Boolean -> + context.feature(StealthMode::class).setStealth( + conversationId, + isChecked + ) + } + + /*//click to delete switch + val clickToDeleteSwitch = Switch(viewModel.context) + clickToDeleteSwitch.setText(context.translation.get("click_to_delete")) + clickToDeleteSwitch.isChecked = clickToDelete.isClickToDelete(conversationId) + applyTheme(viewModel, clickToDeleteSwitch) + clickToDeleteSwitch.setOnCheckedChangeListener { buttonView: CompoundButton?, isChecked: Boolean -> + clickToDelete.setClickToDelete( + conversationId, + isChecked + ) + }*/ + /* if (configManager.getBoolean(ConfigCategory.EXTRAS, "conversation_export") + .isState() + ) viewConsumer.accept(exportButton) + if (configManager.getBoolean(ConfigCategory.PRIVACY, "click_to_delete") + .isState() + ) viewConsumer.accept(clickToDeleteSwitch)*/ + viewConsumer(stealthSwitch) + viewConsumer(previewButton) + } +}+ \ 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 @@ -0,0 +1,82 @@ +package me.rhunk.snapenhance.features.impl.ui.menus.impl + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import de.robv.android.xposed.XposedBridge +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.features.impl.ui.menus.AbstractMenu +import me.rhunk.snapenhance.features.impl.ui.menus.ViewAppearanceHelper.applyTheme + +class OperaContextActionMenu : AbstractMenu() { + private val contextCardsScrollView by lazy { + context.resources.getIdentifier("context_cards_scroll_view", "id", Constants.SNAPCHAT_PACKAGE_NAME) + } + + /* + LinearLayout : + - LinearLayout: + - SnapFontTextView + - ImageView + - LinearLayout: + - SnapFontTextView + - ImageView + - LinearLayout: + - SnapFontTextView + - ImageView + */ + private fun isViewGroupButtonMenuContainer(viewGroup: ViewGroup): Boolean { + if (viewGroup !is LinearLayout) return false + val children = ArrayList<View>() + for (i in 0 until viewGroup.getChildCount()) + children.add(viewGroup.getChildAt(i)) + return if (children.any { view: View? -> view !is LinearLayout }) + false + else children.map { view: View -> view as LinearLayout } + .any { linearLayout: LinearLayout -> + val viewChildren = ArrayList<View>() + for (i in 0 until linearLayout.childCount) viewChildren.add( + linearLayout.getChildAt( + i + ) + ) + viewChildren.any { viewChild: View -> + viewChild.javaClass.name.endsWith("SnapFontTextView") + } + } + } + + @SuppressLint("SetTextI18n") + fun inject(viewGroup: ViewGroup, childView: View) { + try { + if (viewGroup.parent !is ScrollView) return + val parent = viewGroup.parent as ScrollView + if (parent.id != contextCardsScrollView) return + if (childView !is LinearLayout) return + if (!isViewGroupButtonMenuContainer(childView as ViewGroup)) return + + val linearLayout = LinearLayout(childView.getContext()) + linearLayout.orientation = LinearLayout.VERTICAL + linearLayout.gravity = Gravity.CENTER + linearLayout.layoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + val button = Button(childView.getContext()) + button.text = context.translation.get("download_opera") + button.setOnClickListener { context.feature(MediaDownloader::class).downloadLastOperaMediaAsync() } + applyTheme(linearLayout, button) + linearLayout.addView(button) + (childView as ViewGroup).addView(linearLayout, 0) + } catch (e: Throwable) { + Logger.xposedLog(e) + } + } +} 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 @@ -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 @@ -0,0 +1,72 @@ +package me.rhunk.snapenhance.hook + +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import java.lang.reflect.Member +import java.util.function.Consumer + +@Suppress("UNCHECKED_CAST") +class HookAdapter( + private val methodHookParam: XC_MethodHook.MethodHookParam<*> +) { + fun <T : Any> thisObject(): T { + return methodHookParam.thisObject as T + } + + fun method(): Member { + return methodHookParam.method + } + + fun <T : Any> arg(index: Int): T { + return methodHookParam.args[index] as T + } + + fun <T : Any> argNullable(index: Int): T? { + return methodHookParam.args[index] as T? + } + + fun setArg(index: Int, value: Any?) { + if (index < 0 || index >= methodHookParam.args.size) return + methodHookParam.args[index] = value + } + + fun args(): Array<Any> { + return methodHookParam.args + } + + fun getResult(): Any? { + return methodHookParam.result + } + + fun setResult(result: Any?) { + methodHookParam.result = result + } + + fun setThrowable(throwable: Throwable) { + methodHookParam.throwable = throwable + } + + fun throwable(): Throwable? { + return methodHookParam.throwable + } + + fun invokeOriginal(): Any? { + return XposedBridge.invokeOriginalMethod(method(), thisObject(), args()) + } + + fun invokeOriginal(args: Array<Any>): Any? { + return XposedBridge.invokeOriginalMethod(method(), thisObject(), args) + } + + fun invokeOriginalSafe(errorCallback: Consumer<Throwable>) { + invokeOriginalSafe(args(), errorCallback) + } + + fun invokeOriginalSafe(args: Array<Any>, errorCallback: Consumer<Throwable>) { + runCatching { + setResult(XposedBridge.invokeOriginalMethod(method(), thisObject(), args)) + }.onFailure { + errorCallback.accept(it) + } + } +}+ \ 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 @@ -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 @@ -0,0 +1,94 @@ +package me.rhunk.snapenhance.hook + +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import java.lang.reflect.Member + +object Hooker { + private fun newMethodHook( + stage: HookStage, + consumer: (HookAdapter) -> Unit, + filter: ((HookAdapter) -> Boolean) = { true } + ) = object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam<*>) { + if (stage != HookStage.BEFORE) return + with(HookAdapter(param)) { + if (!filter(this)) return + consumer(this) + } + } + + override fun afterHookedMethod(param: MethodHookParam<*>) { + if (stage != HookStage.AFTER) return + with(HookAdapter(param)) { + if (!filter(this)) return + consumer(this) + } + } + } + + fun hook( + clazz: Class<*>, + methodName: String, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer)) + + fun hook( + clazz: Class<*>, + methodName: String, + stage: HookStage, + filter: (HookAdapter) -> Boolean, + consumer: (HookAdapter) -> Unit + ): Set<XC_MethodHook.Unhook> = XposedBridge.hookAllMethods(clazz, methodName, newMethodHook(stage, consumer, filter)) + + fun hook( + member: Member, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ): XC_MethodHook.Unhook { + return XposedBridge.hookMethod(member, newMethodHook(stage, consumer)) + } + + fun hook( + member: Member, + stage: HookStage, + filter: ((HookAdapter) -> Boolean), + consumer: (HookAdapter) -> Unit + ): XC_MethodHook.Unhook { + return XposedBridge.hookMethod(member, newMethodHook(stage, consumer, filter)) + } + + + fun hookConstructor( + clazz: Class<*>, + stage: HookStage, + consumer: (HookAdapter) -> Unit + ) { + XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer)) + } + + fun hookConstructor( + clazz: Class<*>, + stage: HookStage, + filter: ((HookAdapter) -> Boolean), + consumer: (HookAdapter) -> Unit + ) { + XposedBridge.hookAllConstructors(clazz, newMethodHook(stage, consumer, filter)) + } + + fun ephemeralHookObjectMethod( + clazz: Class<*>, + instance: Any, + methodName: String, + stage: HookStage, + hookConsumer: (HookAdapter) -> Unit + ) { + val unhooks: MutableSet<XC_MethodHook.Unhook> = HashSet() + hook(clazz, methodName, stage) { param-> + if (param.thisObject<Any>() != instance) return@hook + hookConsumer(param) + unhooks.forEach{ it.unhook() } + }.also { unhooks.addAll(it) } + } +}+ \ 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 @@ -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 @@ -0,0 +1,63 @@ +package me.rhunk.snapenhance.manager.impl + +import com.google.gson.JsonObject +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest +import me.rhunk.snapenhance.config.ConfigAccessor +import me.rhunk.snapenhance.config.ConfigProperty +import me.rhunk.snapenhance.manager.Manager +import java.nio.charset.StandardCharsets + +class ConfigManager( + private val context: ModContext, + config: MutableMap<ConfigProperty, Any?> = mutableMapOf() +) : ConfigAccessor(config), Manager { + + private val propertyList = ConfigProperty.sortedByCategory() + + override fun init() { + //generate default config + propertyList.forEach { key -> + set(key, key.defaultValue) + } + + if (!context.bridgeClient.isFileExists(FileAccessRequest.FileType.CONFIG)) { + writeConfig() + return + } + + runCatching { + loadConfig() + }.onFailure { + Logger.xposedLog("Failed to load config", it) + writeConfig() + } + } + + private fun loadConfig() { + val configContent = context.bridgeClient.createAndReadFile( + FileAccessRequest.FileType.CONFIG, + "{}".toByteArray(Charsets.UTF_8) + ) + val configObject: JsonObject = context.gson.fromJson( + String(configContent, StandardCharsets.UTF_8), + JsonObject::class.java + ) + propertyList.forEach { key -> + val value = context.gson.fromJson(configObject.get(key.name), key.defaultValue.javaClass) ?: key.defaultValue + set(key, value) + } + } + + fun writeConfig() { + val configObject = JsonObject() + propertyList.forEach { key -> + configObject.add(key.name, context.gson.toJsonTree(get(key))) + } + context.bridgeClient.writeFile( + FileAccessRequest.FileType.CONFIG, + context.gson.toJson(configObject).toByteArray(Charsets.UTF_8) + ) + } +}+ \ 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 @@ -0,0 +1,93 @@ +package me.rhunk.snapenhance.manager.impl + +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.features.Feature +import me.rhunk.snapenhance.features.FeatureLoadParams +import me.rhunk.snapenhance.features.impl.ConfigEnumKeys +import me.rhunk.snapenhance.features.impl.Messaging +import me.rhunk.snapenhance.features.impl.downloader.MediaDownloader +import me.rhunk.snapenhance.features.impl.extras.AutoSave +import me.rhunk.snapenhance.features.impl.extras.Notifications +import me.rhunk.snapenhance.features.impl.extras.SnapchatPlus +import me.rhunk.snapenhance.features.impl.privacy.DisableMetrics +import me.rhunk.snapenhance.features.impl.privacy.PreventScreenshotDetections +import me.rhunk.snapenhance.features.impl.spy.* +import me.rhunk.snapenhance.features.impl.ui.UITweaks +import me.rhunk.snapenhance.features.impl.ui.menus.MenuViewInjector +import me.rhunk.snapenhance.manager.Manager +import java.util.concurrent.Executors +import kotlin.reflect.KClass + +class FeatureManager(private val context: ModContext) : Manager { + private val asyncLoadExecutorService = Executors.newCachedThreadPool() + private val features = mutableListOf<Feature>() + + private fun register(featureClass: KClass<out Feature>) { + runCatching { + with(featureClass.java.newInstance()) { + if (loadParams and FeatureLoadParams.NO_INIT != 0) return@with + context = this@FeatureManager.context + features.add(this) + } + }.onFailure { + Logger.xposedLog("Failed to register feature ${featureClass.simpleName}", it) + } + } + + @Suppress("UNCHECKED_CAST") + fun <T : Feature> get(featureClass: KClass<T>): T? { + return features.find { it::class == featureClass } as? T + } + + override fun init() { + register(Messaging::class) + register(MediaDownloader::class) + register(StealthMode::class) + register(MenuViewInjector::class) + register(PreventReadReceipts::class) + register(AnonymousStoryViewing::class) + register(MessageLogger::class) + register(SnapchatPlus::class) + register(DisableMetrics::class) + register(PreventScreenshotDetections::class) + register(PreventStatusNotifications::class) + register(Notifications::class) + register(AutoSave::class) + register(UITweaks::class) + register(ConfigEnumKeys::class) + + initializeFeatures() + } + + private fun featureInitializer(isAsync: Boolean, param: Int, action: (Feature) -> Unit) { + features.forEach { feature -> + if (feature.loadParams and param == 0) return@forEach + val callback = { + runCatching { + action(feature) + }.onFailure { + Logger.xposedLog("Failed to init feature ${feature.nameKey}", it) + } + } + if (!isAsync) { + callback() + return@forEach + } + asyncLoadExecutorService.submit { + callback() + } + } + } + + private fun initializeFeatures() { + //TODO: async called when all features are initiated ? + featureInitializer(false, FeatureLoadParams.INIT_SYNC) { it.init() } + featureInitializer(true, FeatureLoadParams.INIT_ASYNC) { it.asyncInit() } + } + + override fun onActivityCreate() { + featureInitializer(false, FeatureLoadParams.ACTIVITY_CREATE_SYNC) { it.onActivityCreate() } + featureInitializer(true, FeatureLoadParams.ACTIVITY_CREATE_ASYNC) { it.asyncOnActivityCreate() } + } +}+ \ 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 @@ -0,0 +1,178 @@ +package me.rhunk.snapenhance.manager.impl + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import dalvik.system.DexFile +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.Constants +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.ModContext +import me.rhunk.snapenhance.bridge.common.impl.FileAccessRequest +import me.rhunk.snapenhance.manager.Manager +import me.rhunk.snapenhance.mapping.Mapper +import me.rhunk.snapenhance.mapping.impl.CallbackMapper +import me.rhunk.snapenhance.mapping.impl.EnumMapper +import me.rhunk.snapenhance.mapping.impl.OperaPageViewControllerMapper +import me.rhunk.snapenhance.mapping.impl.PlusSubscriptionMapper +import me.rhunk.snapenhance.util.getObjectField +import java.io.FileNotFoundException +import java.nio.charset.StandardCharsets +import java.util.concurrent.ConcurrentHashMap + +@Suppress("UNCHECKED_CAST") +class MappingManager(private val context: ModContext) : Manager { + private val mappers = mutableListOf<Mapper>().apply { + add(CallbackMapper()) + add(EnumMapper()) + add(OperaPageViewControllerMapper()) + add(PlusSubscriptionMapper()) + } + + private val mappings = ConcurrentHashMap<String, Any>() + private var snapBuildNumber = 0 + + override fun init() { + val currentBuildNumber = context.androidContext.packageManager.getPackageInfo( + Constants.SNAPCHAT_PACKAGE_NAME, + 0 + ).longVersionCode.toInt() + snapBuildNumber = currentBuildNumber + + if (context.bridgeClient.isFileExists(FileAccessRequest.FileType.MAPPINGS)) { + runCatching { + loadCached() + }.onFailure { + if (it is FileNotFoundException) { + Logger.xposedLog(it) + context.forceCloseApp() + } + Logger.error("Failed to load cached mappings", it) + } + return + } + refresh() + } + + private fun loadCached() { + if (!context.bridgeClient.isFileExists(FileAccessRequest.FileType.MAPPINGS)) { + Logger.xposedLog("Mappings file does not exist") + return + } + val mappingsObject = JsonParser.parseString( + String( + context.bridgeClient.readFile(FileAccessRequest.FileType.MAPPINGS), + StandardCharsets.UTF_8 + ) + ).asJsonObject.also { + snapBuildNumber = it["snap_build_number"].asInt + } + + mappingsObject.entrySet().forEach { (key, value): Map.Entry<String, JsonElement> -> + if (value.isJsonArray) { + mappings[key] = context.gson.fromJson(value, ArrayList::class.java) + return@forEach + } + if (value.isJsonObject) { + mappings[key] = context.gson.fromJson(value, ConcurrentHashMap::class.java) + return@forEach + } + mappings[key] = value.asString + } + } + + private fun executeMappers(classes: List<Class<*>>) = runBlocking { + val jobs = mutableListOf<Job>() + mappers.forEach { mapper -> + mapper.context = context + launch { + runCatching { + mapper.useClasses(context.androidContext.classLoader, classes, mappings) + }.onFailure { + Logger.error("Failed to execute mapper ${mapper.javaClass.simpleName}", it) + } + }.also { jobs.add(it) } + } + jobs.forEach { it.join() } + } + + @Suppress("UNCHECKED_CAST", "DEPRECATION") + private fun refresh() { + context.shortToast("Loading mappings (this may take a while)") + val classes: MutableList<Class<*>> = ArrayList() + + val classLoader = context.androidContext.classLoader + val dexPathList = classLoader.getObjectField("pathList") + val dexElements = dexPathList.getObjectField("dexElements") as Array<Any> + + dexElements.forEach { dexElement: Any -> + val dexFile = dexElement.getObjectField("dexFile") as DexFile + dexFile.entries().toList().forEach fileList@{ className -> + //ignore classes without a dot in them + if (className.contains(".") && !className.startsWith("com.snap")) return@fileList + runCatching { + classLoader.loadClass(className)?.let { classes.add(it) } + } + } + } + + executeMappers(classes) + write() + } + + private fun write() { + val mappingsObject = JsonObject() + mappingsObject.addProperty("snap_build_number", snapBuildNumber) + mappings.forEach { (key, value) -> + if (value is List<*>) { + mappingsObject.add(key, context.gson.toJsonTree(value)) + return@forEach + } + if (value is Map<*, *>) { + mappingsObject.add(key, context.gson.toJsonTree(value)) + return@forEach + } + mappingsObject.addProperty(key, value.toString()) + } + + context.bridgeClient.writeFile( + FileAccessRequest.FileType.MAPPINGS, + mappingsObject.toString().toByteArray() + ) + } + + fun getMappedObject(key: String): Any { + if (mappings.containsKey(key)) { + return mappings[key]!! + } + Logger.xposedLog("Mapping not found deleting cache") + context.bridgeClient.deleteFile(FileAccessRequest.FileType.MAPPINGS) + throw Exception("No mapping found for $key") + } + + fun getMappedClass(className: String): Class<*> { + return context.androidContext.classLoader.loadClass(getMappedObject(className) as String) + } + + fun getMappedClass(key: String, subKey: String): Class<*> { + return context.androidContext.classLoader.loadClass(getMappedValue(key, subKey)) + } + + fun getMappedValue(key: String): String { + return getMappedObject(key) as String + } + + fun <T : Any> getMappedList(key: String): List<T> { + return listOf(getMappedObject(key) as List<T>).flatten() + } + + fun getMappedValue(key: String, subKey: String): String { + return getMappedMap(key)[subKey] as String + } + + fun getMappedMap(key: String): Map<String, *> { + return getMappedObject(key) as Map<String, *> + } +}+ \ 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 @@ -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 @@ -0,0 +1,13 @@ +package me.rhunk.snapenhance.mapping + +import me.rhunk.snapenhance.ModContext + +abstract class Mapper { + lateinit var context: ModContext + + abstract fun useClasses( + classLoader: ClassLoader, + classes: List<Class<*>>, + mappings: MutableMap<String, Any> + ) +} 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 @@ -0,0 +1,29 @@ +package me.rhunk.snapenhance.mapping.impl + +import me.rhunk.snapenhance.Logger.debug +import me.rhunk.snapenhance.mapping.Mapper +import java.lang.reflect.Method +import java.lang.reflect.Modifier + +class CallbackMapper : Mapper() { + override fun useClasses( + classLoader: ClassLoader, + classes: List<Class<*>>, + mappings: MutableMap<String, Any> + ) { + val callbackMappings = HashMap<String, String>() + classes.forEach { clazz -> + val superClass = clazz.superclass ?: return@forEach + if (!superClass.name.endsWith("Callback") || superClass.name.endsWith("\$Callback")) return@forEach + if (!Modifier.isAbstract(superClass.modifiers)) return@forEach + + if (superClass.declaredMethods.any { method: Method -> + method.name == "onError" + }) { + callbackMappings[superClass.simpleName] = clazz.name + } + } + debug("found " + callbackMappings.size + " callbacks") + mappings["callbacks"] = callbackMappings + } +} 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 @@ -0,0 +1,41 @@ +package me.rhunk.snapenhance.mapping.impl + +import me.rhunk.snapenhance.Logger.debug +import me.rhunk.snapenhance.mapping.Mapper +import java.lang.reflect.Method +import java.util.* + + +class EnumMapper : Mapper() { + override fun useClasses( + classLoader: ClassLoader, + classes: List<Class<*>>, + mappings: MutableMap<String, Any> + ) { + val enumMappings = HashMap<String, String>() + //settings classes have an interface that extends Serializable and contains the getName method + //this enum classes are used to store the settings values + //Setting enum class -> implements an interface -> getName method + classes.forEach { clazz -> + if (!clazz.isEnum) return@forEach + if (clazz.interfaces.isEmpty()) return@forEach + val serializableInterfaceClass = clazz.interfaces[0] + if (serializableInterfaceClass.methods + .filter { method: Method -> method.declaringClass == serializableInterfaceClass } + .none { method: Method -> method.name == "getName" } + ) return@forEach + + runCatching { + val getEnumNameMethod = + serializableInterfaceClass.methods.first { it!!.returnType.isEnum } + clazz.enumConstants?.onEach { enumConstant -> + val enumName = + Objects.requireNonNull(getEnumNameMethod.invoke(enumConstant)).toString() + enumMappings[enumName] = clazz.name + } + } + } + debug("found " + enumMappings.size + " enums") + mappings["enums"] = enumMappings + } +}+ \ 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 @@ -0,0 +1,77 @@ +package me.rhunk.snapenhance.mapping.impl + +import me.rhunk.snapenhance.mapping.Mapper +import me.rhunk.snapenhance.util.ReflectionHelper +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.* + + +class OperaPageViewControllerMapper : Mapper() { + override fun useClasses( + classLoader: ClassLoader, + classes: List<Class<*>>, + mappings: MutableMap<String, Any> + ) { + var operaPageViewControllerClass: Class<*>? = null + for (aClass in classes) { + if (!Modifier.isAbstract(aClass.modifiers)) continue + if (aClass.interfaces.isEmpty()) continue + val foundFields = Arrays.stream(aClass.declaredFields).filter { field: Field -> + val modifiers = field.modifiers + Modifier.isStatic(modifiers) && Modifier.isFinal( + modifiers + ) + }.filter { field: Field -> + try { + return@filter "ad_product_type" == String.format("%s", field[null]) + } catch (e: IllegalAccessException) { + e.printStackTrace() + } + false + }.count() + if (foundFields == 0L) continue + operaPageViewControllerClass = aClass + break + } + if (operaPageViewControllerClass == null) throw RuntimeException("OperaPageViewController not found") + + val members = HashMap<String, String>() + members["Class"] = operaPageViewControllerClass.name + + operaPageViewControllerClass.fields.forEach { field -> + val fieldType = field.type + if (fieldType.isEnum) { + fieldType.enumConstants.firstOrNull { enumConstant: Any -> enumConstant.toString() == "FULLY_DISPLAYED" } + .let { members["viewStateField"] = field.name } + } + if (fieldType == ArrayList::class.java) { + members["layerListField"] = field.name + } + } + val enumViewStateClass = operaPageViewControllerClass.fields.first { field: Field -> + field.name == members["viewStateField"] + }.type + + //find the method that call the onDisplayStateChange method + members["onDisplayStateChange"] = + operaPageViewControllerClass.methods.first { method: Method -> + if (method.returnType != Void.TYPE || method.parameterTypes.size != 1) return@first false + val firstParameterClass = method.parameterTypes[0] + //check if the class contains a field with the enumViewStateClass type + ReflectionHelper.searchFieldByType(firstParameterClass, enumViewStateClass) != null + }.name + + //find the method that call the onDisplayStateChange method from gestures + members["onDisplayStateChange2"] = + operaPageViewControllerClass.methods.first { method: Method -> + if (method.returnType != Void.TYPE || method.parameterTypes.size != 2) return@first false + val firstParameterClass = method.parameterTypes[0] + val secondParameterClass = method.parameterTypes[1] + firstParameterClass.isEnum && secondParameterClass.isEnum + }.name + + mappings["OperaPageViewController"] = members + } +} 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 @@ -0,0 +1,32 @@ +package me.rhunk.snapenhance.mapping.impl + +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.mapping.Mapper +import java.lang.reflect.Field +import java.lang.reflect.Method + + +class PlusSubscriptionMapper : Mapper() { + override fun useClasses( + classLoader: ClassLoader, + classes: List<Class<*>>, + mappings: MutableMap<String, Any> + ) { + //find a method that contains annotations with isSubscribed + val loadSubscriptionMethod = context.classCache.composerLocalSubscriptionStore.declaredMethods.first { method: Method -> + val returnType = method.returnType + returnType.declaredFields.any { field: Field -> + field.declaredAnnotations.any { annotation: Annotation -> + annotation.toString().contains("isSubscribed") + } + } + } + //get the first param of the method which is the PlusSubscriptionState class + val plusSubscriptionStateClass = loadSubscriptionMethod.parameterTypes[0] + //get the first param of the constructor of PlusSubscriptionState which is the SubscriptionInfo class + val subscriptionInfoClass = plusSubscriptionStateClass.constructors[0].parameterTypes[0] + Logger.debug("subscriptionInfoClass ${subscriptionInfoClass.name}") + + mappings["SubscriptionInfoClass"] = subscriptionInfoClass.name + } +}+ \ 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 @@ -0,0 +1,93 @@ +package me.rhunk.snapenhance.util + +import de.robv.android.xposed.XC_MethodHook +import me.rhunk.snapenhance.hook.HookAdapter +import me.rhunk.snapenhance.hook.HookStage +import me.rhunk.snapenhance.hook.Hooker +import java.lang.reflect.Constructor +import java.lang.reflect.Field +import java.lang.reflect.Modifier + +class CallbackBuilder( + private val callbackClass: Class<*> +) { + internal class Override( + val methodName: String, + val callback: (HookAdapter) -> Unit + ) + + private val methodOverrides = mutableListOf<Override>() + + fun override(methodName: String, callback: (HookAdapter) -> Unit = {}): CallbackBuilder { + methodOverrides.add(Override(methodName, callback)) + return this + } + + fun build(): Any { + //get the first param of the first constructor to get the class of the invoker + val invokerClass: Class<*> = callbackClass.constructors[0].parameterTypes[0] + //get the invoker field based on the invoker class + val invokerField = callbackClass.fields.first { field: Field -> + field.type.isAssignableFrom(invokerClass) + } + //get the callback field based on the callback class + val callbackInstance = createEmptyObject(callbackClass.constructors[0])!! + val callbackInstanceHashCode: Int = callbackInstance.hashCode() + val callbackInstanceClass = callbackInstance.javaClass + + val unhooks = mutableListOf<XC_MethodHook.Unhook>() + + callbackInstanceClass.methods.forEach { method -> + if (method.declaringClass != callbackInstanceClass) return@forEach + if (Modifier.isPrivate(method.modifiers)) return@forEach + + //default hook that unhooks the callback and returns null + val defaultHook: (HookAdapter) -> Boolean = defaultHook@{ + //checking invokerField ensure that's the callback was created by the CallbackBuilder + if (invokerField.get(it.thisObject()) != null) return@defaultHook false + if ((it.thisObject() as Any).hashCode() != callbackInstanceHashCode) return@defaultHook false + + it.setResult(null) + unhooks.forEach { unhook -> unhook.unhook() } + true + } + + var hook: (HookAdapter) -> Unit = { defaultHook(it) } + + //override the default hook if the method is in the override list + methodOverrides.find { it.methodName == method.name }?.run { + hook = { + if (defaultHook(it)) { + callback(it) + } + } + } + + unhooks.add(Hooker.hook(method, HookStage.BEFORE, hook)) + } + return callbackInstance + } + + companion object { + private fun createEmptyObject(constructor: Constructor<*>): Any? { + //compute the args for the constructor with null or default primitive values + val args = constructor.parameterTypes.map { type: Class<*> -> + if (type.isPrimitive) { + when (type.name) { + "boolean" -> return@map false + "byte" -> return@map 0.toByte() + "char" -> return@map 0.toChar() + "short" -> return@map 0.toShort() + "int" -> return@map 0 + "long" -> return@map 0L + "float" -> return@map 0f + "double" -> return@map 0.0 + } + } + null + }.toTypedArray() + return constructor.newInstance(*args) + } + + } +}+ \ 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 @@ -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 @@ -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 @@ -0,0 +1,118 @@ +package me.rhunk.snapenhance.util + +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.util.* + +object ReflectionHelper { + /** + * Searches for a field with a class that has a method with the specified name + */ + fun searchFieldWithClassMethod(clazz: Class<*>, methodName: String): Field? { + return clazz.declaredFields.firstOrNull { f: Field? -> + try { + return@firstOrNull Arrays.stream( + f!!.type.declaredMethods + ).anyMatch { method: Method -> method.name == methodName } + } catch (e: Exception) { + return@firstOrNull false + } + } + } + + fun searchFieldByType(clazz: Class<*>, type: Class<*>): Field? { + return clazz.declaredFields.firstOrNull { f: Field? -> f!!.type == type } + } + + fun searchFieldTypeInSuperClasses(clazz: Class<*>, type: Class<*>): Field? { + val field = searchFieldByType(clazz, type) + if (field != null) { + return field + } + val superclass = clazz.superclass + return superclass?.let { searchFieldTypeInSuperClasses(it, type) } + } + + fun searchFieldStartsWithToString( + clazz: Class<*>, + instance: Any, + toString: String? + ): Field? { + return clazz.declaredFields.firstOrNull { f: Field -> + try { + f.isAccessible = true + return@firstOrNull Objects.requireNonNull(f[instance]).toString() + .startsWith( + toString!! + ) + } catch (e: Throwable) { + return@firstOrNull false + } + } + } + + + fun searchFieldContainsToString( + clazz: Class<*>, + instance: Any?, + toString: String? + ): Field? { + return clazz.declaredFields.firstOrNull { f: Field -> + try { + f.isAccessible = true + return@firstOrNull Objects.requireNonNull(f[instance]).toString() + .contains(toString!!) + } catch (e: Throwable) { + return@firstOrNull false + } + } + } + + fun searchFirstFieldTypeInClassRecursive(clazz: Class<*>, type: Class<*>): Field? { + return clazz.declaredFields.firstOrNull { + val field = searchFieldByType(it.type, type) + return@firstOrNull field != null + } + } + + /** + * Searches for a field with a class that has a method with the specified return type + */ + fun searchMethodWithReturnType(clazz: Class<*>, returnType: Class<*>): Method? { + return clazz.declaredMethods.first { m: Method -> m.returnType == returnType } + } + + /** + * Searches for a field with a class that has a method with the specified return type and parameter types + */ + fun searchMethodWithParameterAndReturnType( + aClass: Class<*>, + returnType: Class<*>, + vararg parameters: Class<*> + ): Method? { + return aClass.declaredMethods.firstOrNull { m: Method -> + if (m.returnType != returnType) { + return@firstOrNull false + } + val parameterTypes = m.parameterTypes + if (parameterTypes.size != parameters.size) { + return@firstOrNull false + } + for (i in parameterTypes.indices) { + if (parameterTypes[i] != parameters[i]) { + return@firstOrNull false + } + } + true + } + } + + fun getDeclaredFieldsRecursively(clazz: Class<*>): List<Field> { + val fields = clazz.declaredFields.toMutableList() + val superclass = clazz.superclass + if (superclass != null) { + fields.addAll(getDeclaredFieldsRecursively(superclass)) + } + return fields + } +}+ \ 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 @@ -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 @@ -0,0 +1,83 @@ +package me.rhunk.snapenhance.util.download + +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import me.rhunk.snapenhance.Constants +import java.io.InputStream +import java.net.URL +import javax.net.ssl.HttpsURLConnection + +object CdnDownloader { + const val BOLT_CDN_U = "https://bolt-gcdn.sc-cdn.net/u/" + const val BOLT_CDN_X = "https://bolt-gcdn.sc-cdn.net/x/" + const val CF_ST_CDN_D = "https://cf-st.sc-cdn.net/d/" + const val CF_ST_CDN_F = "https://cf-st.sc-cdn.net/f/" + const val CF_ST_CDN_H = "https://cf-st.sc-cdn.net/h/" + const val CF_ST_CDN_G = "https://cf-st.sc-cdn.net/g/" + const val CF_ST_CDN_O = "https://cf-st.sc-cdn.net/o/" + const val CF_ST_CDN_I = "https://cf-st.sc-cdn.net/i/" + const val CF_ST_CDN_J = "https://cf-st.sc-cdn.net/j/" + const val CF_ST_CDN_C = "https://cf-st.sc-cdn.net/c/" + const val CF_ST_CDN_AA = "https://cf-st.sc-cdn.net/aa/" + + private val keyCache: MutableMap<String, String> = mutableMapOf() + + fun downloadRemoteContent( + key: String, + vararg endpoints: String + ): InputStream? = runBlocking { + if (keyCache.containsKey(key)) { + return@runBlocking queryRemoteContent( + keyCache[key]!! + ) + } + val jobs = mutableListOf<Job>() + var inputStream: InputStream? = null + + endpoints.forEach { + launch { + val url = it + key + val result = queryRemoteContent(url) + if (result != null) { + keyCache[key] = url + inputStream = result + jobs.forEach { it.cancel() } + } + }.also { jobs.add(it) } + } + jobs.forEach { it.join() } + inputStream + } + + + private fun queryRemoteContent(url: String): InputStream? { + try { + val connection = URL(url).openConnection() as HttpsURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = 5000 + connection.setRequestProperty("User-Agent", Constants.USER_AGENT) + return connection.inputStream + } catch (ignored: Throwable) { + } + return null + } + + //TODO: automatically detect the correct endpoint + fun downloadWithDefaultEndpoints(key: String): InputStream? { + return downloadRemoteContent( + key, + CF_ST_CDN_F, + CF_ST_CDN_H, + BOLT_CDN_U, + BOLT_CDN_X, + CF_ST_CDN_O, + CF_ST_CDN_I, + CF_ST_CDN_C, + CF_ST_CDN_J, + CF_ST_CDN_AA, + CF_ST_CDN_G, + CF_ST_CDN_D + ) + } +} 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 @@ -0,0 +1,116 @@ +package me.rhunk.snapenhance.util.download + +import de.robv.android.xposed.XposedBridge +import me.rhunk.snapenhance.Logger +import me.rhunk.snapenhance.Logger.debug +import me.rhunk.snapenhance.ModContext +import java.io.BufferedReader +import java.io.File +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.Socket +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ThreadLocalRandom +import java.util.function.Consumer + +class DownloadServer( + private val context: ModContext +) { + private val port = ThreadLocalRandom.current().nextInt(10000, 65535) + + private val cachedData = ConcurrentHashMap<String, ByteArray>() + private var serverSocket: ServerSocket? = null + + fun startFileDownload(destination: File, content: ByteArray, callback: Consumer<Boolean>) { + val httpKey = java.lang.Long.toHexString(System.nanoTime()) + ensureServerStarted { + putDownloadableContent(httpKey, content) + val url = "http://127.0.0.1:$port/$httpKey" + context.executeAsync { + val result: Boolean = context.bridgeClient.downloadContent(url, destination.absolutePath) + callback.accept(result) + } + } + } + + private fun ensureServerStarted(callback: Runnable) { + if (serverSocket != null && !serverSocket!!.isClosed) { + callback.run() + return + } + Thread { + try { + debug("started web server on 127.0.0.1:$port") + serverSocket = ServerSocket(port) + callback.run() + while (!serverSocket!!.isClosed) { + try { + val socket = serverSocket!!.accept() + Thread { handleRequest(socket) }.start() + } catch (e: Throwable) { + Logger.xposedLog(e) + } + } + } catch (e: Throwable) { + Logger.xposedLog(e) + } + }.start() + } + + fun putDownloadableContent(key: String, data: ByteArray) { + cachedData[key] = data + } + + private fun handleRequest(socket: Socket) { + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + val outputStream = socket.getOutputStream() + val writer = PrintWriter(outputStream) + val line = reader.readLine() ?: return + val close = Runnable { + try { + reader.close() + writer.close() + outputStream.close() + socket.close() + } catch (e: Throwable) { + Logger.xposedLog(e) + } + } + val parse = StringTokenizer(line) + val method = parse.nextToken().uppercase(Locale.getDefault()) + var fileRequested = parse.nextToken().lowercase(Locale.getDefault()) + if (method != "GET") { + writer.println("HTTP/1.1 501 Not Implemented") + writer.println("Content-type: " + "application/octet-stream") + writer.println("Content-length: " + 0) + writer.println() + writer.flush() + close.run() + return + } + if (fileRequested.startsWith("/")) { + fileRequested = fileRequested.substring(1) + } + if (!cachedData.containsKey(fileRequested)) { + writer.println("HTTP/1.1 404 Not Found") + writer.println("Content-type: " + "application/octet-stream") + writer.println("Content-length: " + 0) + writer.println() + writer.flush() + close.run() + return + } + val data = cachedData[fileRequested]!! + writer.println("HTTP/1.1 200 OK") + writer.println("Content-type: " + "application/octet-stream") + writer.println("Content-length: " + data.size) + writer.println() + writer.flush() + outputStream.write(data, 0, data.size) + outputStream.flush() + close.run() + cachedData.remove(fileRequested) + } +}+ \ 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 @@ -0,0 +1,122 @@ +package me.rhunk.snapenhance.util.protobuf + +data class Wire(val type: Int, val value: Any) + +class ProtoReader(private val buffer: ByteArray) { + private var offset: Int = 0 + private val values = mutableMapOf<Int, MutableList<Wire>>() + + init { + read() + } + + fun getBuffer() = buffer + + private fun readByte() = buffer[offset++] + + private fun readVarInt(): Long { + var result = 0L + var shift = 0 + while (true) { + val b = readByte() + result = result or ((b.toLong() and 0x7F) shl shift) + if (b.toInt() and 0x80 == 0) { + break + } + shift += 7 + } + return result + } + + private fun read() { + while (offset < buffer.size) { + val tag = readVarInt().toInt() + val id = tag ushr 3 + val type = tag and 0x7 + try { + val value = when (type) { + 0 -> readVarInt().toString().toByteArray() + 2 -> { + val length = readVarInt().toInt() + val value = buffer.copyOfRange(offset, offset + length) + offset += length + value + } + else -> break + } + values.getOrPut(id) { mutableListOf() }.add(Wire(type, value)) + } catch (t: Throwable) { + values.clear() + break + } + } + } + + fun readPath(vararg ids: Int, reader: (ProtoReader.() -> Unit)? = null): ProtoReader? { + var thisReader = this + ids.forEach { id -> + if (!thisReader.exists(id)) { + return null + } + thisReader = ProtoReader(thisReader.get(id) as ByteArray) + } + if (reader != null) { + thisReader.reader() + } + return thisReader + } + + fun pathExists(vararg ids: Int): Boolean { + var thisReader = this + ids.forEach { id -> + if (!thisReader.exists(id)) { + return false + } + thisReader = ProtoReader(thisReader.get(id) as ByteArray) + } + return true + } + + fun getByteArray(id: Int) = values[id]?.first()?.value as ByteArray? + fun getByteArray(vararg ids: Int): ByteArray? { + if (ids.isEmpty() || ids.size < 2) { + return null + } + val lastId = ids.last() + var value: ByteArray? = null + readPath(*(ids.copyOfRange(0, ids.size - 1))) { + value = getByteArray(lastId) + } + return value + } + + fun getString(id: Int) = getByteArray(id)?.toString(Charsets.UTF_8) + fun getString(vararg ids: Int) = getByteArray(*ids)?.toString(Charsets.UTF_8) + + fun getInt(id: Int) = getString(id)?.toInt() + fun getInt(vararg ids: Int) = getString(*ids)?.toInt() + + fun getLong(id: Int) = getString(id)?.toLong() + fun getLong(vararg ids: Int) = getString(*ids)?.toLong() + + fun exists(id: Int) = values.containsKey(id) + + fun get(id: Int) = values[id]!!.first().value + + fun isValid() = values.isNotEmpty() + + fun getCount(id: Int) = values[id]!!.size + + fun each(id: Int, reader: ProtoReader.(index: Int) -> Unit) { + values[id]!!.forEachIndexed { index, _ -> + ProtoReader(values[id]!![index].value as ByteArray).reader(index) + } + } + + fun eachExists(id: Int, reader: ProtoReader.(index: Int) -> Unit) { + if (!exists(id)) { + return + } + each(id, reader) + } +}+ \ 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 @@ -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 @@ -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 @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + +</resources>+ \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string-array name="sc_scope"> + <item>com.snapchat.android</item> + </string-array> +</resources>+ \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml @@ -0,0 +1,33 @@ +<resources> + <string name="app_name" translatable="false">Snap Enhance</string> + <string name="property.save_folder">Save folder</string> + <string name="property.prevent_read_receipts">Prevent read receipts</string> + <string name="property.hide_bitmoji_presence">Hide Bitmoji presence</string> + <string name="property.show_message_content">Show message content</string> + <string name="property.message_logger">Message logger</string> + <string name="property.media_downloader_feature">Media downloader feature</string> + <string name="property.download_stories">Download stories</string> + <string name="property.download_public_stories">Download public stories</string> + <string name="property.download_spotlight">Download spotlight</string> + <string name="property.overlay_merge">Overlay merge</string> + <string name="property.download_inchat_snaps">Download in chat snaps</string> + <string name="property.disable_metrics">Disable metrics</string> + <string name="property.prevent_screenshot">Prevent screenshot</string> + <string name="property.anonymous_story_view">Anonymous story view</string> + <string name="property.hide_typing_notification">Hide typing notification</string> + <string name="property.menu_slot_id">Menu slot id</string> + <string name="property.message_preview_length">Message preview length</string> + <string name="property.auto_save">Auto save</string> + <string name="property.external_media_as_snap">External media as snap</string> + <string name="property.conversation_export">Conversation export</string> + <string name="property.snapchat_plus">Snapchat Plus</string> + <string name="property.remove_voice_record_button">Remove voice record button</string> + <string name="property.remove_stickers_button">Remove stickers button</string> + <string name="property.remove_cognac_button">Remove cognac button</string> + <string name="property.remove_callbuttons">Remove call buttons</string> + <string name="property.long_snap_sending">Long snap sending</string> + <string name="property.block_ads">Block ads</string> + <string name="property.streakexpirationinfo">Streak Expiration Info</string> + <string name="property.new_map_ui">New map ui</string> + <string name="property.use_download_manager">Use download manager</string> +</resources>+ \ No newline at end of file diff --git a/build.gradle 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 @@ -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 Binary files differ. diff --git a/gradle/wrapper/gradle-wrapper.properties 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 @@ -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 @@ -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 @@ -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'