diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt b/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt index 839ec4e..c22a78d 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt @@ -2,13 +2,16 @@ package io.github.revenge.xposed class Constants { companion object { + const val TARGET_PACKAGE = "com.discord" + const val TARGET_ACTIVITY = "$TARGET_PACKAGE.react_activities.ReactActivity" + const val FILES_DIR = "files/pyoncord" const val CACHE_DIR = "cache/pyoncord" - const val BUNDLE_FILE = "bundle.js" - + const val MAIN_SCRIPT_FILE = "bundle.js" const val LOG_TAG = "Revenge" + const val LOADER_NAME = "RevengeXposed" const val USER_AGENT = "RevengeXposed" } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Main.kt b/app/src/main/kotlin/io/github/revenge/xposed/Main.kt index 47e3108..ea99f66 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/Main.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/Main.kt @@ -1,251 +1,70 @@ package io.github.revenge.xposed import android.app.Activity -import android.content.res.AssetManager -import android.content.res.Resources +import android.content.Context +import android.content.ContextWrapper import android.os.Bundle -import android.widget.Toast import de.robv.android.xposed.IXposedHookLoadPackage -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.IXposedHookZygoteInit import de.robv.android.xposed.callbacks.XC_LoadPackage -import io.github.revenge.xposed.modules.FontsModule -import io.github.revenge.xposed.modules.LogBoxModule -import io.github.revenge.xposed.modules.NativeBridgeModule -import io.github.revenge.xposed.modules.SysColorsModule -import io.github.revenge.xposed.modules.ThemeModule -import io.github.revenge.xposed.Utils.Companion.JSON -import io.github.revenge.xposed.Utils.Log -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.engine.cio.* -import io.ktor.client.plugins.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import kotlinx.coroutines.* -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.* -import java.io.File +import io.github.revenge.xposed.modules.* +import io.github.revenge.xposed.modules.appearance.FontsModule +import io.github.revenge.xposed.modules.appearance.SysColorsModule +import io.github.revenge.xposed.modules.appearance.ThemeModule +import io.github.revenge.xposed.modules.bridge.BridgeModule -@Serializable -data class CustomLoadUrl( - val enabled: Boolean = false, - val url: String = "" -) - -@Serializable -data class LoaderConfig( - val customLoadUrl: CustomLoadUrl = CustomLoadUrl() -) - -class Main : Module(), IXposedHookLoadPackage { - private lateinit var preloadsDir: File - private lateinit var bundle: File - private lateinit var httpJob: Deferred - - private lateinit var activity: Activity - private val onActivityCreate: ((Activity) -> Unit) -> Unit = - { if (::activity.isInitialized) it(activity) else onActivityCreateCallbacks.add(it) } - private val onActivityCreateCallbacks = mutableSetOf<(Activity) -> Unit>() - - val PRELOADS_DIR = "preloads" - - val ETAG_FILE = "etag.txt" - val CONFIG_FILE = "loader.json" - - val LOADER_NAME = "RevengeXposed" - - val DEFAULT_BUNDLE_URL = "https://github.com/revenge-mod/revenge-bundle/releases/latest/download/revenge.min.js" - - val TARGET_PACKAGE = "com.discord" - val TARGET_ACTIVITY = "$TARGET_PACKAGE.react_activities.ReactActivity" - - private val modules = listOf( +class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit { + private var hooked = false + private val modules = mutableListOf( + HookScriptLoaderModule(), + BridgeModule(), + UpdaterModule(), + FixResourcesModule(), + LogBoxModule(), + FontsModule(), ThemeModule(), SysColorsModule(), - FontsModule(), - LogBoxModule(), - NativeBridgeModule() ) - private fun getPayloadString(): String = JSON.encodeToString( - buildJsonObject { - put("loaderName", LOADER_NAME) - put("loaderVersion", BuildConfig.VERSION_NAME) - for (module in modules) module.buildPayload(this) - } - ) + init { + modules.add(PayloadGlobalModule(modules)) + } + + override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) { + for (module in modules) module.onInit(startupParam) + } override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with(param) { - val reactActivity = classLoader.safeLoadClass(TARGET_ACTIVITY) - ?: return + if (hooked) return + + val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY) + + ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) { + after { + this@Main.onContext(args[0] as Context) + } + } reactActivity.hookMethod("onCreate", Bundle::class.java) { - before { - activity = thisObject as Activity - this@Main.onCreate(activity) + after { + this@Main.onActivity(thisObject as Activity) } } - init(param) + this@Main.onLoad(param) + + hooked = true } - override fun onCreate(activity: Activity) { - for (module in modules) module.onCreate(activity) - for (cb in onActivityCreateCallbacks) cb(activity) - onActivityCreateCallbacks.clear() + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { + for (module in modules) module.onLoad(packageParam) } - private fun init( - param: XC_LoadPackage.LoadPackageParam, - ) = with(param) { - val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() } - val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() } - - preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() } - bundle = File(cacheDir, Constants.BUNDLE_FILE).apply { asFile() } - - val etag = File(cacheDir, ETAG_FILE).apply { asFile() } - val configFile = File(filesDir, CONFIG_FILE).apply { asFile() } - - val config = runCatching { - JSON.decodeFromString(configFile.readText()) - }.getOrDefault(LoaderConfig()) - - httpJob = MainScope().async(Dispatchers.IO) { - runCatching { - val client = HttpClient(CIO) { - expectSuccess = true - install(HttpTimeout) { - requestTimeoutMillis = if (bundle.exists()) 5000 else 10000 - } - install(UserAgent) { agent = Constants.USER_AGENT } - } - - val url = config.customLoadUrl.takeIf { it.enabled }?.url - ?: DEFAULT_BUNDLE_URL - - Log.i("Fetching JS bundle from $url") - - val response: HttpResponse = client.get(url) { - headers { - if (etag.exists() && bundle.exists()) { - append(HttpHeaders.IfNoneMatch, etag.readText()) - } - } - } - - bundle.writeBytes(response.body()) - response.headers["Etag"]?.let(etag::writeText) ?: etag.delete() - - }.onFailure { e -> - if (e is RedirectResponseException && - e.response.status == HttpStatusCode.NotModified - ) { - Log.i("Server responded with 304 - no changes") - } else { - onActivityCreate { activity -> - activity.runOnUiThread { - Toast.makeText( - activity.applicationContext, - "Failed to fetch JS bundle, Revenge may not load!", - Toast.LENGTH_SHORT - ).show() - } - } - Log.e("Failed to download bundle", e) - } - } - } - - listOf( - "com.facebook.react.runtime.ReactInstance$1", - "com.facebook.react.bridge.CatalystInstanceImpl" - ).mapNotNull { classLoader.safeLoadClass(it) } - .forEach { hookLoadScript(it) } - - for (module in modules) module.onLoad(param) - - // Fix resource package name mismatch - if (packageName != TARGET_PACKAGE) { - Resources::class.java.hookMethod( - "getIdentifier", - String::class.java, - String::class.java, - String::class.java - ) { - before { - if (args[2] == packageName) args[2] = TARGET_PACKAGE - } - } - } + override fun onContext(context: Context) { + for (module in modules) module.onContext(context) } - private fun hookLoadScript( - instance: Class<*> - ) { - val loadScriptFromAssets = instance.method( - "loadScriptFromAssets", - AssetManager::class.java, - String::class.java, - Boolean::class.javaPrimitiveType - ) - val loadScriptFromFile = instance.method( - "loadScriptFromFile", - String::class.java, - String::class.java, - Boolean::class.javaPrimitiveType - ) - - val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit = - { param, key, json -> - runCatching { - instance.method( - "setGlobalVariable", - String::class.java, - String::class.java - ).invoke(param.thisObject, key, json) - }.onFailure { - // Bridgeless compatibility - File(preloadsDir, "rv_globals_$key.js").apply { - writeText("this[${JSON.encodeToString(key)}]=$json") - XposedBridge.invokeOriginalMethod( - loadScriptFromFile, - param.thisObject, - arrayOf(absolutePath, absolutePath, param.args[2]) - ) - delete() - } - } - } - - val patch = object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - runBlocking { httpJob.join() } - setGlobalVariable(param, "__PYON_LOADER__", getPayloadString()) - - preloadsDir.walk() - .filter { it.isFile && it.extension == "js" } - .forEach { file -> - XposedBridge.invokeOriginalMethod( - loadScriptFromFile, - param.thisObject, - arrayOf(file.absolutePath, file.absolutePath, param.args[2]) - ) - } - - XposedBridge.invokeOriginalMethod( - loadScriptFromFile, - param.thisObject, - arrayOf(bundle.absolutePath, bundle.absolutePath, param.args[2]) - ) - } - } - - for (method in listOf(loadScriptFromAssets, loadScriptFromFile)) method.hook(patch) + override fun onActivity(activity: Activity) { + for (module in modules) module.onActivity(activity) } - - private fun ClassLoader.safeLoadClass(name: String): Class<*>? = - runCatching { loadClass(name) }.getOrNull() } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Module.kt b/app/src/main/kotlin/io/github/revenge/xposed/Module.kt index c951f8b..5d5e80a 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/Module.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/Module.kt @@ -1,45 +1,106 @@ package io.github.revenge.xposed import android.app.Activity +import android.content.Context +import android.os.Build +import de.robv.android.xposed.IXposedHookZygoteInit import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.callbacks.XC_LoadPackage -import java.lang.reflect.Method import kotlinx.serialization.json.JsonObjectBuilder import java.io.File +import java.lang.reflect.Method + +data class AppInfo( + val name: String, + val packageName: String, + val version: String, + val versionCode: Long, +) abstract class Module { - open fun buildPayload(builder: JsonObjectBuilder) {} + /** + * Builds a JSON payload to be injected into the JavaScript context. + */ + @Deprecated("This will be removed in future versions. Payloads can be replaced via synchronous bridge methods.") + open fun buildPayload(builder: JsonObjectBuilder) { + } + /** + * Called during Zygote initialization. + */ + open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {} + + /** + * Called when a package is loaded. + * + * Hooks should typically be set up here. + * + * Bridge methods that do not require [Context] can also be registered here. + */ open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {} - open fun onCreate(activity: Activity) {} + /** + * Called after [Context] has been attached to a [android.content.ContextWrapper] + * via [android.content.ContextWrapper.attachBaseContext]. + * + * This may be called multiple times for different [Context]s, e.g. when the app is killed by the system. + * + * For bridge methods that require access to [Context], register them here. + */ + open fun onContext(context: Context) {} + + /** + * Called after an [Activity.onCreate] method is executed. + * + * UI code that requires an [Activity] context can be placed here. + * + * Bridge methods must not be registered here, as it can be too late and cause crashes. + * + * If you need to register bridge methods that require an [Activity], do it in [onContext] and make it async. + * + * Once an [Activity] is available, execute the queued calls. Then from JS, call the method asynchronously. + */ + open fun onActivity(activity: Activity) {} + + protected fun Context.getAppInfo(): AppInfo { + val pInfo = packageManager.getPackageInfo(packageName, 0) + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode + else @Suppress("DEPRECATION") pInfo.versionCode.toLong() + + return AppInfo( + packageManager.getApplicationLabel(applicationInfo).toString(), + packageName, + pInfo.versionName ?: versionCode.toString(), + versionCode, + ) + } protected fun File.asDir() { - if (!this.isDirectory()) this.delete() + if (!this.isDirectory) this.delete() this.mkdirs() } protected fun File.asFile() { - if (!this.isFile()) this.deleteRecursively() + if (!this.isFile) this.deleteRecursively() } protected fun Class<*>.method( - name: String, - vararg params: Class<*>? + name: String, vararg params: Class<*>? ): Method = getDeclaredMethod(name, *params).apply { isAccessible = true } - protected fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook = - XposedBridge.hookMethod(this, hook) + protected fun ClassLoader.safeLoadClass(name: String): Class<*>? = runCatching { loadClass(name) }.getOrNull() + + protected fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook = XposedBridge.hookMethod(this, hook) + + protected fun Method.hook(block: MethodHookBuilder.() -> Unit): XC_MethodHook.Unhook = + hook(MethodHookBuilder().apply(block).build()) protected fun Class<*>.hookMethod( - name: String, - vararg params: Class<*>?, - block: MethodHookBuilder.() -> Unit - ): XC_MethodHook.Unhook = - method(name, *params).hook(MethodHookBuilder().apply(block).build()) + name: String, vararg params: Class<*>?, block: MethodHookBuilder.() -> Unit + ): XC_MethodHook.Unhook = method(name, *params).hook(MethodHookBuilder().apply(block).build()) protected class MethodHookBuilder { private var beforeBlock: (HookScope.() -> Unit)? = null @@ -53,40 +114,33 @@ abstract class Module { afterBlock = block } - fun build(): XC_MethodHook = - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - val b = beforeBlock - if (b != null) { - val scope = - HookScope( - param = param, - callSuper = { p -> - super.beforeHookedMethod(p) - } - ) - scope.b() - } else { - super.beforeHookedMethod(param) - } - } - - override fun afterHookedMethod(param: MethodHookParam) { - val a = afterBlock - if (a != null) { - val scope = - HookScope( - param = param, - callSuper = { p -> - super.afterHookedMethod(p) - } - ) - scope.a() - } else { - super.afterHookedMethod(param) - } + fun build(): XC_MethodHook = object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val b = beforeBlock + if (b != null) { + val scope = HookScope( + param = param, proceed = { p -> + super.beforeHookedMethod(p) + }) + scope.b() + } else { + super.beforeHookedMethod(param) } } + + override fun afterHookedMethod(param: MethodHookParam) { + val a = afterBlock + if (a != null) { + val scope = HookScope( + param = param, proceed = { p -> + super.afterHookedMethod(p) + }) + scope.a() + } else { + super.afterHookedMethod(param) + } + } + } } /** @@ -98,18 +152,17 @@ abstract class Module { * - Accessors for `thisObject`, `args`, `result`, and `throwable` * * @property param The [XC_MethodHook.MethodHookParam] for the current hook. - * @property callSuper Function that calls the super method. + * @property proceed Function that calls the super method. */ protected class HookScope internal constructor( - val param: XC_MethodHook.MethodHookParam, - private val callSuper: (XC_MethodHook.MethodHookParam) -> Unit + val param: XC_MethodHook.MethodHookParam, private val proceed: (XC_MethodHook.MethodHookParam) -> Unit ) { /** * Continues with the default XC_MethodHook super behavior. * Equivalent to calling `super.beforeHookedMethod(param)` or * `super.afterHookedMethod(param)` depending on the phase. */ - fun proceed() = callSuper(param) + fun proceed() = proceed(param) val thisObject: Any? get() = param.thisObject diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt b/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt index 5dcca36..0ec4738 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt @@ -1,10 +1,20 @@ package io.github.revenge.xposed +import android.app.AndroidAppHelper +import android.content.Intent import kotlinx.serialization.json.Json +import kotlin.system.exitProcess class Utils { companion object { val JSON = Json { ignoreUnknownKeys = true } + + fun reloadApp() { + val application = AndroidAppHelper.currentApplication() + val intent = application.packageManager.getLaunchIntentForPackage(application.packageName) + application.startActivity(Intent.makeRestartActivityTask(intent!!.component)) + exitProcess(0) + } } object Log { @@ -12,5 +22,7 @@ class Utils { fun e(msg: String, throwable: Throwable) = android.util.Log.e(Constants.LOG_TAG, msg, throwable) fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg) fun i(msg: String, throwable: Throwable) = android.util.Log.i(Constants.LOG_TAG, msg, throwable) + fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg) + fun w(msg: String, throwable: Throwable) = android.util.Log.w(Constants.LOG_TAG, msg, throwable) } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt new file mode 100644 index 0000000..7ce62a8 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt @@ -0,0 +1,23 @@ +package io.github.revenge.xposed.modules + +import android.content.res.Resources +import de.robv.android.xposed.callbacks.XC_LoadPackage +import io.github.revenge.xposed.Constants.Companion.TARGET_PACKAGE +import io.github.revenge.xposed.Module + +/** + * Hooks [Resources.getIdentifier] to fix resource package name mismatch. + */ +class FixResourcesModule : Module() { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + if (packageName != TARGET_PACKAGE) { + Resources::class.java.hookMethod( + "getIdentifier", String::class.java, String::class.java, String::class.java + ) { + before { + if (args[2] == packageName) args[2] = TARGET_PACKAGE + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt new file mode 100644 index 0000000..2bf78cd --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt @@ -0,0 +1,112 @@ +package io.github.revenge.xposed.modules + +import android.content.res.AssetManager +import android.content.res.XModuleResources +import de.robv.android.xposed.IXposedHookZygoteInit +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage +import io.github.revenge.xposed.Constants +import io.github.revenge.xposed.Module +import io.github.revenge.xposed.Utils.Log +import io.github.revenge.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR +import kotlinx.coroutines.runBlocking +import java.io.File + +/** + * Hooks React Native's script loading methods to load custom scripts and bundles. + * + * Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files directory. + * + * The main bundle should be placed in the [Constants.CACHE_DIR] directory named [Constants.MAIN_SCRIPT_FILE]. + * If the bundle file does not exist, it will attempt to load `assets://revenge.bundle` from the module's assets. + */ +class HookScriptLoaderModule : Module() { + private lateinit var preloadsDir: File + private lateinit var mainScript: File + + private lateinit var modulePath: String + private lateinit var resources: XModuleResources + + companion object { + const val PRELOADS_DIR = "preloads" + } + + override fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) { + this@HookScriptLoaderModule.modulePath = startupParam.modulePath + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() } + val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() } + + preloadsDir = File(filesDir, PRELOADS_DIR).apply { asFile() } + mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() } + + listOf( + "com.facebook.react.runtime.ReactInstance$1", + // TODO: Remove once Discord fully switches to Bridgeless + "com.facebook.react.bridge.CatalystInstanceImpl" + ).mapNotNull { classLoader.safeLoadClass(it) } + .forEach { hook(it) } + } + + private fun hook(instance: Class<*>) { + val loadScriptFromAssets = instance.method( + "loadScriptFromAssets", + AssetManager::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ) + + val loadScriptFromFile = instance.method( + "loadScriptFromFile", + String::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ) + + loadScriptFromAssets.hook { + before { + Log.i("Received call to loadScriptFromAssets, running our scripts first... (asset: ${args[1]}, sync: ${args[2]})") + + // TODO: Is there a better way to do this? + runBlocking { UpdaterModule.job?.join() } + + val loadSynchronously = args[2] + val runScriptFile = { file: File -> + Log.i("Loading script: ${file.absolutePath}") + + XposedBridge.invokeOriginalMethod( + loadScriptFromFile, + thisObject, + arrayOf(file.absolutePath, file.absolutePath, loadSynchronously) + ) + + Unit + } + + try { + preloadsDir.walk() + .filter { it.isFile } + .forEach(runScriptFile) + + if (mainScript.exists()) runScriptFile(mainScript) + else { + Log.i("Main script does not exist, falling back") + + if (!::resources.isInitialized) resources = + XModuleResources.createInstance(modulePath, null) + + XposedBridge.invokeOriginalMethod( + loadScriptFromAssets, + thisObject, + arrayOf(resources.assets, "assets://revenge.bundle", loadSynchronously) + ) + } + } catch (e: Throwable) { + Log.e("Unable to run scripts:", e) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt index 4c7f553..451785c 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt @@ -1,115 +1,80 @@ package io.github.revenge.xposed.modules import android.app.AlertDialog -import android.app.AndroidAppHelper import android.content.Context -import android.content.Intent -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage import io.github.revenge.xposed.Constants import io.github.revenge.xposed.Module -import io.github.revenge.xposed.Utils +import io.github.revenge.xposed.Utils.Companion.reloadApp +import io.github.revenge.xposed.Utils.Log import java.io.File -import kotlin.system.exitProcess -class LogBoxModule: Module() { +class LogBoxModule : Module() { lateinit var packageParam: XC_LoadPackage.LoadPackageParam + lateinit var bridgeDevSupportManagerClass: Class<*> - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { this@LogBoxModule.packageParam = packageParam val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") - val bridgeDevSupportManagerClass = classLoader.loadClass("com.facebook.react.devsupport.BridgeDevSupportManager") - - val mReactInstanceDevHelperField = XposedHelpers.findField(bridgeDevSupportManagerClass, "mReactInstanceDevHelper") + bridgeDevSupportManagerClass = classLoader.loadClass("com.facebook.react.devsupport.BridgeDevSupportManager") val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" } val handleReloadJSMethod = bridgeDevSupportManagerClass.methods.first { it.name == "handleReloadJS" } - val showDevOptionsDialogMethod = bridgeDevSupportManagerClass.methods.first { it.name == "showDevOptionsDialog" } - - var alertDialog: AlertDialog? = null // This enables the LogBox and opens dev option on shake - XposedBridge.hookMethod( - getUseDeveloperSupportMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - param.result = true - } + getUseDeveloperSupportMethod.hook { + before { + result = true } - ) - - // Triggered on shake - XposedBridge.hookMethod( - showDevOptionsDialogMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - try { - if (alertDialog == null) { - val mReactInstanceDevHelper = - mReactInstanceDevHelperField.get(param.thisObject) - val getCurrentActivityMethod = - mReactInstanceDevHelper.javaClass.methods.first { it.name == "getCurrentActivity" } - - val context = - getCurrentActivityMethod.invoke(mReactInstanceDevHelper) as Context - - alertDialog = showRecoveryAlert(context) { - alertDialog = null - } - } - } catch (ex: Exception) { - Utils.Log.e("Failed to show dev options dialog: $ex") - alertDialog = null - } - - // Ignore the original dev menu - param.result = null - } - } - ) + } // Replace the method to direct relaunch the app instead of sending reload command to developer server - XposedBridge.hookMethod( - handleReloadJSMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) = reloadApp() + handleReloadJSMethod.hook { + before { + reloadApp() + result = null } - ) + } return@with } - private fun showRecoveryAlert(context: Context, onClose: () -> Unit): AlertDialog { - return AlertDialog.Builder(context) - .setTitle("Revenge Recovery Options") + override fun onContext(context: Context) { + val showDevOptionsDialogMethod = + bridgeDevSupportManagerClass.methods.first { it.name == "showDevOptionsDialog" } + + // Triggered on shake + showDevOptionsDialogMethod.hook { + before { + try { + showRecoveryAlert(context) + } catch (ex: Exception) { + Log.e("Failed to show dev options dialog: $ex") + } + + // Ignore the original dev menu + param.result = null + } + } + } + + private fun showRecoveryAlert(context: Context) { + AlertDialog.Builder(context).setTitle("Revenge Recovery Options") .setItems(arrayOf("Reload", "Delete bundle.js")) { _, which -> when (which) { 0 -> { reloadApp() } + 1 -> { - val bundleFile = File(packageParam.appInfo.dataDir, "${Constants.CACHE_DIR}/${Constants.BUNDLE_FILE}") + val bundleFile = + File(packageParam.appInfo.dataDir, "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}") if (bundleFile.exists()) bundleFile.delete() reloadApp() } } - - onClose() - } - .setOnDismissListener { - onClose() - } - .show() - } - - private fun reloadApp() { - val application = AndroidAppHelper.currentApplication() - val intent = application.packageManager.getLaunchIntentForPackage(application.packageName) - application.startActivity(Intent.makeRestartActivityTask(intent!!.component)) - exitProcess(0) + }.show() } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/NativeBridgeModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/NativeBridgeModule.kt deleted file mode 100644 index 6e4b186..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/NativeBridgeModule.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.github.revenge.xposed.modules - -import de.robv.android.xposed.callbacks.XC_LoadPackage -import io.github.revenge.xposed.Module -import java.lang.reflect.Method - -/** - * See for possible return types: - * https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19 - * - * You may return a [Unit] and the resulting value will be `null`. - */ -typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any? - -typealias BridgeMethodArgs = ArrayList - -class NativeBridgeModule : Module() { - private lateinit var readableMapGetStringMethod: Method - private lateinit var readableMapToHashMapMethod: Method - private lateinit var argumentsMakeNativeObject: Method - - companion object { - private val CALL_DATA_KEY = "revenge" - private val METHOD_NAME_KEY = "method" - private val METHOD_ARGS_KEY = "args" - - private val methods = mutableMapOf( - "revenge.test" to { - mapOf( - "string" to "string", - "number" to 7256, - "array" to listOf("testing", 527737, listOf(true)), - "object" to mapOf("nested" to true), - "boolean" to false - ) - }, - "revenge.test2" to { it } - ) - - fun registerMethod(name: String, callback: BridgeMethodCallback) { - require(!methods.containsKey(name)) { - "Bridge callback already exists: $name" - } - - methods[name] = callback - } - } - - private fun Any?.toNativeObject(): Any? = - argumentsMakeNativeObject.invoke(null, when (this) { - Unit -> null - else -> this - }) - - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { - val argumentsClass = classLoader.loadClass("com.facebook.react.bridge.Arguments") - val readableMapClass = classLoader.loadClass("com.facebook.react.bridge.ReadableMap") - val promiseClass = classLoader.loadClass("com.facebook.react.bridge.Promise") - - val promiseResolveMethod = promiseClass.method("resolve", Object::class.java) - argumentsMakeNativeObject = - argumentsClass.method("makeNativeObject", Object::class.java) - readableMapGetStringMethod = - readableMapClass.method("getString", String::class.java) - readableMapToHashMapMethod = readableMapClass.method("toHashMap") - - classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager") - .hookMethod("getBBox", Double::class.javaObjectType, readableMapClass) { - before { - callBridgeMethod(readableMapToHashMap(args[1]!!)) - ?.let { result = it.toNativeObject() } - } - } - - classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule") - .hookMethod("readAsDataURL", readableMapClass, promiseClass) { - before { - callBridgeMethod(readableMapToHashMap(args[0]!!)) - ?.let { ret -> - promiseResolveMethod.invoke(args[1], ret.toNativeObject()) - result = null - } - } - } - - return@with - } - - private fun readableMapToHashMap(map: Any): HashMap { - @Suppress("UNCHECKED_CAST") - return readableMapToHashMapMethod.invoke(map) as HashMap - } - - private fun callBridgeMethod(hashMap: HashMap): Map? = try { - val (method, args) = getBridgeCallData(hashMap) ?: return null - val ret = method(args).toNativeObject() - mapOf("result" to ret) - } catch (e: Throwable) { - mapOf("error" to e.toString()) - } - - private fun getBridgeCallData(hashMap: HashMap): Pair? { - @Suppress("UNCHECKED_CAST") - val data = hashMap[CALL_DATA_KEY] as HashMap? - data ?: return null - - @Suppress("UNCHECKED_CAST") - val name = data[METHOD_NAME_KEY] as String - val method = methods[name] - method ?: throw Error("Method not registered: $name") - - @Suppress("UNCHECKED_CAST") - val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs - - return Pair(method, args) - } -} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt new file mode 100644 index 0000000..dd7c83a --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt @@ -0,0 +1,72 @@ +package io.github.revenge.xposed.modules + +import android.content.res.AssetManager +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage +import io.github.revenge.xposed.BuildConfig +import io.github.revenge.xposed.Constants +import io.github.revenge.xposed.Module +import io.github.revenge.xposed.Utils.Companion.JSON +import io.github.revenge.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.File + +// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule if needed +class PayloadGlobalModule(private val modules: List) : Module() { + private companion object { + const val GLOBAL_NAME = "__PYON_LOADER__" + } + + private fun getPayloadString(): String = JSON.encodeToString( + buildJsonObject { + put("loaderName", Constants.LOADER_NAME) + put("loaderVersion", BuildConfig.VERSION_NAME) + @Suppress("DEPRECATION") + for (module in modules) module.buildPayload(this) + }) + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val catalystInstance = classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl") + val reactInstance = classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1") + + val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit = { param, key, json -> + runCatching { + // Attempt old CatalystInstanceImpl method + catalystInstance!!.method( + "setGlobalVariable", String::class.java, String::class.java + ).invoke(param.thisObject, key, json) + }.onFailure { + // Failed (likely because it's a stub), use a workaround + val preloadsDir = File("${appInfo.dataDir}/${Constants.FILES_DIR}", PRELOADS_DIR) + File(preloadsDir, "rv_globals_$key.js").apply { + writeText("this[${JSON.encodeToString(key)}]=$json") + + XposedBridge.invokeOriginalMethod( + reactInstance!!.method( + "loadScriptFromFile", + String::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2]) + ) + + delete() + } + } + } + + val hook = { instance: Class<*> -> + instance.hookMethod( + "loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType + ) { + before { + setGlobalVariable(param, GLOBAL_NAME, getPayloadString()) + } + } + } + + listOf(catalystInstance, reactInstance).forEach { if (it != null) hook(it) } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt new file mode 100644 index 0000000..59df27d --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt @@ -0,0 +1,165 @@ +package io.github.revenge.xposed.modules + +import android.app.Activity +import android.app.AlertDialog +import android.util.AtomicFile +import android.widget.Toast +import androidx.core.util.writeBytes +import de.robv.android.xposed.callbacks.XC_LoadPackage +import io.github.revenge.xposed.Constants +import io.github.revenge.xposed.Module +import io.github.revenge.xposed.Utils.Companion.JSON +import io.github.revenge.xposed.Utils.Companion.reloadApp +import io.github.revenge.xposed.Utils.Log +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import java.io.File + +@Serializable +data class CustomLoadUrl( + val enabled: Boolean = false, val url: String = "" +) + +@Serializable +data class LoaderConfig( + val customLoadUrl: CustomLoadUrl = CustomLoadUrl() +) + +/** + * Module that updates the JS bundle by downloading it from a remote URL. + * + * Shows dialogs when failed allowing retry. + */ +class UpdaterModule : Module() { + private lateinit var config: LoaderConfig + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var error: Throwable? = null + + private lateinit var cacheDir: File + private lateinit var bundle: File + private lateinit var etag: File + + companion object { + @Volatile + var job: Job? = null + private set + + private const val TIMEOUT_CACHED = 5000L + private const val TIMEOUT = 10000L + private const val ETAG_FILE = "etag.txt" + private const val CONFIG_FILE = "loader.json" + + private const val DEFAULT_BUNDLE_URL = + "https://github.com/revenge-mod/revenge-bundle/releases/latest/download/revenge.min.js" + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() } + val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() } + + bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE) + etag = File(cacheDir, ETAG_FILE) + + val configFile = File(filesDir, CONFIG_FILE) + + config = runCatching { + if (configFile.exists()) { + JSON.decodeFromString(configFile.readText()) + } else LoaderConfig() + }.getOrDefault(LoaderConfig()) + + downloadScript() + } + + private fun downloadScript(activity: Activity? = null) { + job = scope.launch { + try { + HttpClient(CIO) { + expectSuccess = false + install(UserAgent) { agent = Constants.USER_AGENT } + install(HttpRedirect) {} + }.use { client -> + val url = config.customLoadUrl.takeIf { it.enabled }?.url ?: DEFAULT_BUNDLE_URL + Log.i("Fetching JS bundle from: $url") + + val response: HttpResponse = client.get(url) { + headers { + if (etag.exists() && bundle.exists()) { + append(HttpHeaders.IfNoneMatch, etag.readText()) + } + } + + // Retries don't need timeout + if (activity != null) { + timeout { + requestTimeoutMillis = if (!bundle.exists()) TIMEOUT else TIMEOUT_CACHED + } + } + } + + when (response.status) { + HttpStatusCode.OK -> { + val bytes: ByteArray = response.body() + AtomicFile(bundle).writeBytes(bytes) + + val newTag = response.headers[HttpHeaders.ETag] + if (!newTag.isNullOrEmpty()) etag.writeText(newTag) else etag.delete() + + Log.i("Bundle updated (${bytes.size} bytes)") + + // This is a retry, so we show a dialog + if (activity != null) { + withContext(Dispatchers.Main) { + AlertDialog.Builder(activity).setTitle("Revenge Update Successful") + .setMessage("A reload is required for changes to take effect.") + .setPositiveButton("Reload") { dialog, _ -> + reloadApp() + dialog.dismiss() + }.setCancelable(false).show() + } + } + } + + HttpStatusCode.NotModified -> { + Log.i("Server responded with 304, no changes") + } + + else -> { + throw ResponseException(response, "Received status: ${response.status}") + } + } + } + } catch (e: Throwable) { + Log.e("Failed to download script", e) + error = e + } + } + } + + override fun onActivity(activity: Activity) { + error ?: return + + AlertDialog.Builder(activity).setTitle("Revenge Update Failed").setMessage( + """ + Unable to download the latest version of Revenge. + This is usually caused by bad network connection. + + Error: ${error?.message ?: error.toString()} + """.trimIndent() + ).setNegativeButton("Dismiss") { dialog, _ -> + dialog.dismiss() + }.setPositiveButton("Retry Update") { dialog, _ -> + error = null + downloadScript(activity) + Toast.makeText(activity, "Retrying download in background...", Toast.LENGTH_SHORT).show() + dialog.dismiss() + }.show() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/FontsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt similarity index 82% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/FontsModule.kt rename to app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt index 76b42cf..0ab7c19 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/FontsModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt @@ -1,31 +1,31 @@ -package io.github.revenge.xposed.modules +package io.github.revenge.xposed.modules.appearance import android.content.res.AssetManager -import android.os.Build import android.graphics.Typeface import android.graphics.Typeface.CustomFallbackBuilder import android.graphics.fonts.Font import android.graphics.fonts.FontFamily +import android.os.Build import de.robv.android.xposed.XC_MethodReplacement import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage import io.github.revenge.xposed.Constants import io.github.revenge.xposed.Module -import io.github.revenge.xposed.Utils.Log import io.github.revenge.xposed.Utils.Companion.JSON -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.* -import java.io.IOException -import java.io.File -import kotlinx.coroutines.* +import io.github.revenge.xposed.Utils.Log import io.ktor.client.* import io.ktor.client.call.* -import io.ktor.client.request.* import io.ktor.client.engine.cio.* -import io.ktor.client.statement.* import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* import io.ktor.http.* -import java.lang.StringBuilder +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.put +import java.io.File +import java.io.IOException @Serializable data class FontDefinition( @@ -35,10 +35,12 @@ data class FontDefinition( val main: Map, ) -class FontsModule: Module() { - private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") - private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") - private val FONTS_ASSET_PATH = "fonts/" +class FontsModule : Module() { + private companion object { + val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") + val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") + const val FONTS_ASSET_PATH = "fonts/" + } private lateinit var fontsDir: File private lateinit var fontsDownloadsDir: File @@ -50,7 +52,7 @@ class FontsModule: Module() { } } - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { XposedHelpers.findAndHookMethod( "com.facebook.react.views.text.ReactFontManager\$Companion", classLoader, @@ -65,14 +67,16 @@ class FontsModule: Module() { val assetManager: AssetManager = param.args[2] as AssetManager return createAssetTypeface(fontFamilyName, style, assetManager) } - }) + }) val fontDefFile = File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply { asFile() } if (!fontDefFile.exists()) return@with val fontDef = try { JSON.decodeFromString(fontDefFile.readText()) - } catch (_: Throwable) { return@with } + } catch (_: Throwable) { + return@with + } fontsDownloadsDir = File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { asDir() } fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() } @@ -96,7 +100,8 @@ class FontsModule: Module() { val url = fontDef.main.getValue(name) try { Log.i("Downloading $name from $url") - val file = File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}").apply { asFile() } + val file = + File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}").apply { asFile() } if (file.exists()) return@async val client = HttpClient(CIO) { @@ -104,9 +109,7 @@ class FontsModule: Module() { } val response: HttpResponse = client.get(url) - - if (response.status == HttpStatusCode.OK) - file.writeBytes(response.body()) + if (response.status == HttpStatusCode.OK) file.writeBytes(response.body()) return@async } catch (e: Throwable) { @@ -114,15 +117,11 @@ class FontsModule: Module() { } } }.awaitAll() - } - - return@with + } } private fun createAssetTypefaceWithFallbacks( - fontFamilyNames: Array, - style: Int, - assetManager: AssetManager + fontFamilyNames: Array, style: Int, assetManager: AssetManager ): Typeface? { val fontFamilies: MutableList = ArrayList() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -137,17 +136,17 @@ class FontsModule: Module() { val family = FontFamily.Builder(font).build() fontFamilies.add(family) } - } catch (_: Throwable) {} + } catch (_: Throwable) { + } for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { for (fileExtension in FILE_EXTENSIONS) { - val fileName = StringBuilder() - .append(fontRootPath) - .append(fontFamilyName) - .append(fileExtension) - .toString() + val fileName = + StringBuilder().append(fontRootPath).append(fontFamilyName).append(fileExtension).toString() try { - val builder = if (fileName[0] == '/') Font.Builder(File(fileName)) else Font.Builder(assetManager, fileName) + val builder = if (fileName[0] == '/') Font.Builder(File(fileName)) else Font.Builder( + assetManager, fileName + ) val font = builder.build() val family = FontFamily.Builder(font).build() fontFamilies.add(family) @@ -180,10 +179,7 @@ class FontsModule: Module() { // This logic attempts to safely check if the frontend code is attempting to use // fallback fonts, and if it is, to use the fallback typeface creation logic. var fontFamilyName: String = fontFamilyName - val fontFamilyNames = - fontFamilyName.split(",".toRegex()) - .dropLastWhile { it.isEmpty() } - .toTypedArray() + val fontFamilyNames = fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() for (i in fontFamilyNames.indices) { fontFamilyNames[i] = fontFamilyNames[i].trim() } @@ -208,24 +204,20 @@ class FontsModule: Module() { if (!file.exists()) throw Exception() return Typeface.createFromFile(file.absolutePath) } - } catch (_: Throwable) {} + } catch (_: Throwable) { + } // Lastly, after all those checks above, this is the original RN logic for // getting the typeface. for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { for (fileExtension in FILE_EXTENSIONS) { - val fileName = StringBuilder() - .append(fontRootPath) - .append(fontFamilyName) - .append(extension) - .append(fileExtension) - .toString() - + val fileName = + StringBuilder().append(fontRootPath).append(fontFamilyName).append(extension).append(fileExtension) + .toString() + return try { - if (fileName[0] == '/') - Typeface.createFromFile(fileName) - else - Typeface.createFromAsset(assetManager, fileName) + if (fileName[0] == '/') Typeface.createFromFile(fileName) + else Typeface.createFromAsset(assetManager, fileName) } catch (_: RuntimeException) { // If the typeface asset does not exist, try another extension. continue diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/SysColorsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt similarity index 84% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/SysColorsModule.kt rename to app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt index 1b21e6c..90715dd 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/SysColorsModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt @@ -1,4 +1,4 @@ -package io.github.revenge.xposed.modules +package io.github.revenge.xposed.modules.appearance import android.R.color import android.app.AndroidAppHelper @@ -6,18 +6,8 @@ import android.content.Context import android.os.Build import androidx.core.content.ContextCompat import io.github.revenge.xposed.Module -import kotlinx.serialization.Serializable import kotlinx.serialization.json.* -@Serializable -data class SysColors( - val neutral1: List, - val neutral2: List, - val accent1: List, - val accent2: List, - val accent3: List -) - class SysColorsModule : Module() { private lateinit var context: Context private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/ThemeModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemeModule.kt similarity index 69% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/ThemeModule.kt rename to app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemeModule.kt index 4e64a5a..975f4a7 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/ThemeModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemeModule.kt @@ -1,24 +1,24 @@ -package io.github.revenge.xposed.modules +package io.github.revenge.xposed.modules.appearance import android.content.Context import android.content.res.Resources -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.callbacks.XC_LoadPackage -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import java.io.File -import kotlinx.serialization.json.* import androidx.core.graphics.toColorInt +import de.robv.android.xposed.callbacks.XC_LoadPackage import io.github.revenge.xposed.Constants import io.github.revenge.xposed.Module import io.github.revenge.xposed.Utils.Companion.JSON +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put +import java.io.File @Serializable data class Author( - val name: String, - val id: String? = null + val name: String, val id: String? = null ) + @Serializable data class ThemeData( val name: String, @@ -28,11 +28,10 @@ data class ThemeData( val semanticColors: Map>? = null, val rawColors: Map? = null ) + @Serializable data class Theme( - val id: String, - val selected: Boolean, - val data: ThemeData + val id: String, val selected: Boolean, val data: ThemeData ) class ThemeModule : Module() { @@ -41,20 +40,21 @@ class ThemeModule : Module() { private var theme: Theme? = null private val rawColorMap = mutableMapOf() - val THEME_FILE = "current-theme.json" + private companion object { + const val THEME_FILE = "current-theme.json" + } @ExperimentalSerializationApi override fun buildPayload(builder: JsonObjectBuilder) { builder.apply { put("hasThemeSupport", true) - if (theme != null) - put("storedTheme", JSON.encodeToJsonElement(theme!!)) - else - put("storedTheme", null) + if (theme != null) put("storedTheme", JSON.encodeToJsonElement(theme!!)) + else put("storedTheme", null) } } - private fun String.fromScreamingSnakeToCamelCase() = this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } } + private fun String.fromScreamingSnakeToCamelCase() = + this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } } override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { param = packageParam @@ -64,7 +64,7 @@ class ThemeModule : Module() { private fun File.isValidish(): Boolean { if (!this.exists()) return false - + val text = this.readText() return text.isNotBlank() && text != "{}" && text != "null" } @@ -72,11 +72,13 @@ class ThemeModule : Module() { private fun getTheme(): Theme? { val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { asFile() } if (!themeFile.isValidish()) return null - + return try { val themeText = themeFile.readText() JSON.decodeFromString(themeText) - } catch (_: Exception) { null } + } catch (_: Exception) { + null + } } fun hookTheme() { @@ -84,14 +86,13 @@ class ThemeModule : Module() { val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme") val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme") - val theme = this.theme - if (theme == null) return + val theme = this.theme ?: return // Apply rawColors - theme.data.rawColors?.forEach { (key, value) -> + theme.data.rawColors?.forEach { (key, value) -> rawColorMap[key.lowercase()] = hexStringToColorInt(value) } - + // Apply semanticColors theme.data.semanticColors?.forEach { (key, value) -> // TEXT_NORMAL -> getTextNormal @@ -114,29 +115,29 @@ class ThemeModule : Module() { ) val getColorCompatLegacy = themeManager.getDeclaredMethod( - "getColorCompat", - Context::class.java, - Int::class.javaPrimitiveType + "getColorCompat", Context::class.java, Int::class.javaPrimitiveType ) - val patch = object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - val arg1 = param.args[0] + val patch = MethodHookBuilder().run { + before { + val arg1 = args[0] val resources = if (arg1 is Context) arg1.resources else (arg1 as Resources) - val name = resources.getResourceEntryName(param.args[1] as Int) + val name = resources.getResourceEntryName(args[1] as Int) - if (rawColorMap[name] != null) param.result = rawColorMap[name] + if (rawColorMap[name] != null) result = rawColorMap[name] } + + build() } - XposedBridge.hookMethod(getColorCompat, patch) - XposedBridge.hookMethod(getColorCompatLegacy, patch) + getColorCompat.hook(patch) + getColorCompatLegacy.hook(patch) } } // Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB" private fun hexStringToColorInt(hexString: String): Int { - return if (hexString.length == 9 ) { + return if (hexString.length == 9) { // Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy val alpha = hexString.substring(7, 9) val rrggbb = hexString.substring(1, 7) @@ -147,12 +148,13 @@ class ThemeModule : Module() { private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) { try { themeClass.getDeclaredMethod(methodName).let { method -> - XposedBridge.hookMethod(method, object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - param.result = themeValue + method.hook { + before { + result = themeValue } - }) + } } - } catch (_: NoSuchMethodException) {} + } catch (_: NoSuchMethodException) { + } } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt new file mode 100644 index 0000000..854ed08 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt @@ -0,0 +1,174 @@ +package io.github.revenge.xposed.modules.bridge + +import de.robv.android.xposed.callbacks.XC_LoadPackage +import io.github.revenge.xposed.BuildConfig +import io.github.revenge.xposed.Constants +import io.github.revenge.xposed.Module +import io.github.revenge.xposed.Utils.Log +import java.io.File +import java.lang.reflect.Method + +/** + * See for possible return types: + * https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19 + * + * You may return a [Unit] and the resulting value will be `null`. + */ +typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any? + +typealias BridgeMethodArgs = ArrayList + +/** + * A module that exposes a bridge for calling methods from JavaScript. + * + * To call a method, pass an object with the following structure to a hooked method: + * ```js + * { + * revenge: { + * method: "method.name", + * args: [arg1, arg2, ...] + * } + * } + * ``` + * + * For methods that accept additional arguments, only the first object argument is used for the bridge call. + * + * The result will be an object with either a `result` or `error` key: + * ```js + * { + * result: ... // The return value of the method + * } + * ``` + * or + * ```js + * { + * error: "Error message" + * } + * ``` + */ +class BridgeModule : Module() { + private lateinit var readableMapGetString: Method + private lateinit var readableMapToHashMap: Method + private lateinit var argumentsMakeNative: Method + + companion object { + private const val CALL_DATA_KEY = "revenge" + private const val METHOD_NAME_KEY = "method" + private const val METHOD_ARGS_KEY = "args" + + private fun File.openFileGuarded() { + if (!this.exists()) throw Error("Path does not exist: $path") + if (!this.isFile) throw Error("Path is not a file: $path") + } + + private val methods: MutableMap = mutableMapOf("revenge.info" to { + mapOf( + "name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE + ) + }, "revenge.test" to { + mapOf( + "string" to "string", + "number" to 7256, + "array" to listOf("testing", 527737, listOf(true)), + "object" to mapOf("nested" to true), + "boolean" to false, + "args" to it, + ) + }, "revenge.fs.delete" to { + val (path) = it + File(path as String).run { + if (this.isDirectory) this.deleteRecursively() + else this.delete() + } + }, "revenge.fs.exists" to { + val (path) = it + File(path as String).exists() + }, "revenge.fs.read" to { it -> + val (path) = it + val file = File(path as String).apply { openFileGuarded() } + + file.bufferedReader().use { it.readText() } + }, "revenge.fs.write" to { it -> + val (path, contents) = it + val file = File(path as String).apply { openFileGuarded() } + + file.writeText(contents as String) + }) + + /** + * Registers a bridge method that can be called from JavaScript. + * + * If the method is already registered, it will be overwritten. + * + * @param name The name of the method to register. + * @param callback The callback to invoke when the method is called. + */ + fun registerMethod(name: String, callback: BridgeMethodCallback) { + if (methods.containsKey(name)) Log.w("Bridge method already exists and will be overridden: $name") + methods[name] = callback + } + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments") + val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap") + val promise = classLoader.loadClass("com.facebook.react.bridge.Promise") + + val promiseResolve = promise.method("resolve", Object::class.java) + argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java) + readableMapGetString = readableMap.method("getString", String::class.java) + readableMapToHashMap = readableMap.method("toHashMap") + + classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager") + .hookMethod("getBBox", Double::class.javaObjectType, readableMap) { + before { + callBridgeMethod(readableMapToHashMap(args[1]!!))?.let { result = it.toNativeObject() } + } + } + + classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule") + .hookMethod("readAsDataURL", readableMap, promise) { + before { + val (readableMap, promise) = args + callBridgeMethod(readableMapToHashMap(readableMap!!))?.let { + promiseResolve.invoke(promise, it.toNativeObject()) + result = null + } + } + } + + return@with + } + + private fun Any?.toNativeObject(): Any? = argumentsMakeNative.invoke( + null, when (this) { + Unit -> null + else -> this + } + ) + + private fun readableMapToHashMap(map: Any): HashMap { + @Suppress("UNCHECKED_CAST") return readableMapToHashMap.invoke(map) as HashMap + } + + private fun callBridgeMethod(hashMap: HashMap): Map? = try { + val (method, args) = getBridgeCallData(hashMap) ?: return null + val ret = method(args).toNativeObject() + mapOf("result" to ret) + } catch (e: Throwable) { + mapOf("error" to e.toString()) + } + + private fun getBridgeCallData(hashMap: HashMap): Pair? { + @Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap? + data ?: return null + + @Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String + val method = methods[name] + method ?: throw Error("Method not registered: $name") + + @Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs + + return Pair(method, args) + } +}