chore: refactor

This commit is contained in:
PalmDevs 2025-09-15 21:43:01 +07:00
parent b12b6ab996
commit 06878b3135
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
14 changed files with 838 additions and 573 deletions

View file

@ -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"
}
}

View file

@ -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<Unit>
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<LoaderConfig>(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()
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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)
}
}
}
}
}

View file

@ -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()
}
}

View file

@ -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<Any>
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<String, BridgeMethodCallback>(
"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<String, Any?> {
@Suppress("UNCHECKED_CAST")
return readableMapToHashMapMethod.invoke(map) as HashMap<String, Any?>
}
private fun callBridgeMethod(hashMap: HashMap<String, Any?>): Map<String, Any?>? = 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<String, Any?>): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
@Suppress("UNCHECKED_CAST")
val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
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)
}
}

View file

@ -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>) : 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) }
}
}

View file

@ -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<LoaderConfig>(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()
}
}

View file

@ -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<String, String>,
)
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<FontDefinition>(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<String>,
style: Int,
assetManager: AssetManager
fontFamilyNames: Array<String>, style: Int, assetManager: AssetManager
): Typeface? {
val fontFamilies: MutableList<FontFamily> = 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

View file

@ -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<String>,
val neutral2: List<String>,
val accent1: List<String>,
val accent2: List<String>,
val accent3: List<String>
)
class SysColorsModule : Module() {
private lateinit var context: Context
private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S

View file

@ -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<String, List<String>>? = null,
val rawColors: Map<String, String>? = 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<String, Int>()
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>(theme!!))
else
put("storedTheme", null)
if (theme != null) put("storedTheme", JSON.encodeToJsonElement<Theme>(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<Theme>(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) {
}
}
}

View file

@ -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<Any>
/**
* 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<String, BridgeMethodCallback> = 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<String, Any?> {
@Suppress("UNCHECKED_CAST") return readableMapToHashMap.invoke(map) as HashMap<String, Any?>
}
private fun callBridgeMethod(hashMap: HashMap<String, Any?>): Map<String, Any?>? = 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<String, Any?>): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
@Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
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)
}
}