feat: refactor

This commit is contained in:
PalmDevs 2025-08-09 20:49:05 +07:00
parent 8ce3e63a58
commit 56ce7f674b
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
8 changed files with 285 additions and 185 deletions

View file

@ -0,0 +1,14 @@
package io.github.revenge.xposed
class Constants {
companion object {
const val FILES_DIR = "files/pyoncord"
const val CACHE_DIR = "cache/pyoncord"
const val BUNDLE_FILE = "bundle.js"
const val LOG_TAG = "Revenge"
const val USER_AGENT = "RevengeXposed"
}
}

View file

@ -11,6 +11,12 @@ import de.robv.android.xposed.IXposedHookLoadPackage
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.modules.FontsModule
import io.github.revenge.xposed.modules.LogBoxModule
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.*
@ -34,12 +40,24 @@ data class LoaderConfig(
val customLoadUrl: CustomLoadUrl = CustomLoadUrl()
)
class Main : IXposedHookLoadPackage {
class Main : Module(), IXposedHookLoadPackage {
private lateinit var preloadsDir: File
private lateinit var bundle: File
private lateinit var onActivityCreate: ((Activity) -> Unit) -> Unit
private lateinit var httpJob: Deferred<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(
ThemeModule(),
SysColorsModule(),
@ -47,16 +65,16 @@ class Main : IXposedHookLoadPackage {
LogBoxModule()
)
private fun buildLoaderJsonString(): String = Json.encodeToString(
private fun getPayloadString(): String = JSON.encodeToString(
buildJsonObject {
put("loaderName", "RevengeXposed")
put("loaderName", LOADER_NAME)
put("loaderVersion", BuildConfig.VERSION_NAME)
modules.forEach { it.buildJson(this) }
for (module in modules) module.buildPayload(this)
}
)
override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with(param) {
val reactActivity = classLoader.safeLoadClass("com.discord.react_activities.ReactActivity")
val reactActivity = classLoader.safeLoadClass(TARGET_ACTIVITY)
?: return
var activity: Activity? = null
@ -64,8 +82,8 @@ class Main : IXposedHookLoadPackage {
reactActivity.hookMethod("onCreate", Bundle::class.java) {
before {
activity = it.thisObject as Activity
onActivityCreateCallbacks.forEach { cb -> cb(activity) }
activity = thisObject as Activity
for (cb in onActivityCreateCallbacks) cb(activity)
onActivityCreateCallbacks.clear()
}
}
@ -80,18 +98,17 @@ class Main : IXposedHookLoadPackage {
private fun init(
param: XC_LoadPackage.LoadPackageParam,
) = with(param) {
val cacheDir = File(appInfo.dataDir, "cache/pyoncord").apply { mkdirs() }
val filesDir = File(appInfo.dataDir, "files/pyoncord").apply { mkdirs() }
val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() }
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
preloadsDir = File(filesDir, "preloads").apply { mkdirs() }
bundle = File(cacheDir, "bundle.js")
preloadsDir = File(filesDir, PRELOADS_DIR).apply { mkdirs() }
bundle = File(cacheDir, Constants.BUNDLE_FILE)
val etag = File(cacheDir, "etag.txt")
val configFile = File(filesDir, "loader.json")
val etag = File(cacheDir, ETAG_FILE)
val configFile = File(filesDir, CONFIG_FILE)
val config = runCatching {
Json { ignoreUnknownKeys = true }
.decodeFromString<LoaderConfig>(configFile.readText())
JSON.decodeFromString<LoaderConfig>(configFile.readText())
}.getOrDefault(LoaderConfig())
httpJob = MainScope().async(Dispatchers.IO) {
@ -101,13 +118,13 @@ class Main : IXposedHookLoadPackage {
install(HttpTimeout) {
requestTimeoutMillis = if (bundle.exists()) 5000 else 10000
}
install(UserAgent) { agent = "RevengeXposed" }
install(UserAgent) { agent = Constants.USER_AGENT }
}
val url = config.customLoadUrl.takeIf { it.enabled }?.url
?: "https://github.com/revenge-mod/revenge-bundle/releases/latest/download/revenge.min.js"
?: DEFAULT_BUNDLE_URL
Log.i("Revenge", "Fetching JS bundle from $url")
Log.i("Fetching JS bundle from $url")
val response: HttpResponse = client.get(url) {
headers {
@ -124,7 +141,7 @@ class Main : IXposedHookLoadPackage {
if (e is RedirectResponseException &&
e.response.status == HttpStatusCode.NotModified
) {
Log.e("Revenge", "Server responded with 304 - no changes")
Log.i("Server responded with 304 - no changes")
} else {
onActivityCreate { activity ->
activity.runOnUiThread {
@ -135,7 +152,7 @@ class Main : IXposedHookLoadPackage {
).show()
}
}
Log.e("Revenge", "Failed to download bundle", e)
Log.e("Failed to download bundle", e)
}
}
}
@ -146,18 +163,18 @@ class Main : IXposedHookLoadPackage {
).mapNotNull { classLoader.safeLoadClass(it) }
.forEach { hookLoadScript(it) }
modules.forEach { it.onInit(param) }
for (module in modules) module.onInit(param)
// Fix resource package name mismatch
if (packageName != "com.discord") {
if (packageName != TARGET_PACKAGE) {
Resources::class.java.hookMethod(
"getIdentifier",
String::class.java,
String::class.java,
String::class.java
) {
before { mh ->
if (mh.args[2] == packageName) mh.args[2] = "com.discord"
before {
if (args[2] == packageName) args[2] = TARGET_PACKAGE
}
}
}
@ -190,7 +207,7 @@ class Main : IXposedHookLoadPackage {
}.onFailure {
// Bridgeless compatibility
File(preloadsDir, "rv_globals_$key.js").apply {
writeText("this[${Json.encodeToString(key)}]=$json")
writeText("this[${JSON.encodeToString(key)}]=$json")
XposedBridge.invokeOriginalMethod(
loadScriptFromFile,
param.thisObject,
@ -204,7 +221,7 @@ class Main : IXposedHookLoadPackage {
val patch = object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
runBlocking { httpJob.join() }
setGlobalVariable(param, "__PYON_LOADER__", buildLoaderJsonString())
setGlobalVariable(param, "__PYON_LOADER__", getPayloadString())
preloadsDir.walk()
.filter { it.isFile && it.extension == "js" }
@ -224,37 +241,9 @@ class Main : IXposedHookLoadPackage {
}
}
listOf(loadScriptFromAssets, loadScriptFromFile).forEach { it.hook(patch) }
for (method in listOf(loadScriptFromAssets, loadScriptFromFile)) method.hook(patch)
}
private fun ClassLoader.safeLoadClass(name: String): Class<*>? =
runCatching { loadClass(name) }.getOrNull()
private fun Class<*>.method(
name: String,
vararg params: Class<*>?
): java.lang.reflect.Method =
getDeclaredMethod(name, *params).apply { isAccessible = true }
private fun java.lang.reflect.Method.hook(hook: XC_MethodHook) =
XposedBridge.hookMethod(this, hook)
private fun Class<*>.hookMethod(
name: String,
vararg params: Class<*>?,
block: MethodHookBuilder.() -> Unit
) = method(name, *params).hook(MethodHookBuilder().apply(block).build())
private class MethodHookBuilder {
private var beforeHook: ((XC_MethodHook.MethodHookParam) -> Unit)? = null
fun before(block: (XC_MethodHook.MethodHookParam) -> Unit) {
beforeHook = block
}
fun build() = object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
beforeHook?.invoke(param)
}
}
}
}

View file

@ -1,9 +1,117 @@
package io.github.revenge.xposed
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
abstract class Module {
open fun buildJson(builder: JsonObjectBuilder) {}
open fun buildPayload(builder: JsonObjectBuilder) {}
open fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) {}
protected fun Class<*>.method(
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 Class<*>.hookMethod(
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
private var afterBlock: (HookScope.() -> Unit)? = null
fun before(block: HookScope.() -> Unit) {
beforeBlock = block
}
fun after(block: HookScope.() -> Unit) {
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)
}
}
}
}
/**
* Scope object passed to before/after hook blocks.
*
* Provides:
* - Access to the [param] object
* - [proceed] to call the original XC_MethodHook super method
* - 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.
*/
protected class HookScope internal constructor(
val param: XC_MethodHook.MethodHookParam,
private val callSuper: (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)
val thisObject: Any? get() = param.thisObject
val args: Array<Any?> get() = param.args
var result: Any?
get() = param.result
set(value) {
param.result = value
}
var throwable: Throwable?
get() = param.throwable
set(value) {
param.throwable = value
}
}
}

View file

@ -0,0 +1,16 @@
package io.github.revenge.xposed
import kotlinx.serialization.json.Json
class Utils {
companion object {
val JSON = Json { ignoreUnknownKeys = true }
}
object Log {
fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg)
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)
}
}

View file

@ -1,7 +1,4 @@
// credits to janisslsm from his PR: https://github.com/vendetta-mod/VendettaXposed/pull/17
// hooks are modified function from RN codebase
package io.github.revenge.xposed
package io.github.revenge.xposed.modules
import android.content.res.AssetManager
import android.os.Build
@ -9,16 +6,18 @@ import android.graphics.Typeface
import android.graphics.Typeface.CustomFallbackBuilder
import android.graphics.fonts.Font
import android.graphics.fonts.FontFamily
import android.util.Log
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.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
@ -26,6 +25,7 @@ import io.ktor.client.engine.cio.*
import io.ktor.client.statement.*
import io.ktor.client.plugins.*
import io.ktor.http.*
import java.lang.StringBuilder
@Serializable
data class FontDefinition(
@ -44,81 +44,15 @@ class FontsModule: Module() {
private lateinit var fontsDownloadsDir: File
private var fontsAbsPath: String? = null
override fun buildJson(builder: JsonObjectBuilder) {
override fun buildPayload(builder: JsonObjectBuilder) {
builder.apply {
put("fontPatch", 2)
}
}
override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) {
try {
// Try to hook the new class (280201+)
hookClass(classLoader, "com.facebook.react.views.text.ReactFontManager\$Companion")
} catch (e: Throwable) {
when (e) {
// Hook old class (280200-)
is NoClassDefFoundError, is XposedHelpers.ClassNotFoundError -> hookClass(classLoader, "com.facebook.react.views.text.ReactFontManager")
else -> throw e
}
}
val fontDefFile = File(appInfo.dataDir, "files/pyoncord/fonts.json")
if (!fontDefFile.exists()) return@with
val fontDef = try {
val json = Json { ignoreUnknownKeys = true }
json.decodeFromString<FontDefinition>(fontDefFile.readText())
} catch (_: Throwable) { return@with }
fontsDownloadsDir = File(appInfo.dataDir, "files/pyoncord/downloads/fonts").apply { mkdirs() }
fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { mkdirs() }
fontsAbsPath = fontsDir.absolutePath + "/"
fontsDir.listFiles()?.forEach { file ->
val fileName = file.name
if (!fileName.startsWith(".")) {
val fontName = fileName.split('.')[0]
if (fontDef.main.keys.none { it == fontName }) {
Log.i("Revenge", "Deleting font file: $fileName")
file.delete()
}
}
}
// These files should be downloaded by the JS side, but oh well
CoroutineScope(Dispatchers.IO).launch {
fontDef.main.keys.map { name ->
async {
val url = fontDef.main.getValue(name)
try {
Log.i("Revenge", "Downloading $name from $url")
val file = File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}")
if (file.exists()) return@async
val client = HttpClient(CIO) {
install(UserAgent) { agent = "RevengeXposed" }
}
val response: HttpResponse = client.get(url)
if (response.status == HttpStatusCode.OK) {
file.writeBytes(response.body())
}
return@async
} catch (e: Throwable) {
Log.e("Revenge", "Failed to download fonts ($name from $url)", e)
}
}
}.awaitAll()
}
return@with
}
private fun hookClass(classLoader: ClassLoader, className: String) {
XposedHelpers.findAndHookMethod(
className,
"com.facebook.react.views.text.ReactFontManager\$Companion",
classLoader,
"createAssetTypeface",
String::class.java,
@ -131,7 +65,58 @@ 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")
if (!fontDefFile.exists()) return@with
val fontDef = try {
JSON.decodeFromString<FontDefinition>(fontDefFile.readText())
} catch (_: Throwable) { return@with }
fontsDownloadsDir = File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { mkdirs() }
fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { mkdirs() }
fontsAbsPath = fontsDir.absolutePath + "/"
fontsDir.listFiles()?.forEach { file ->
val fileName = file.name
if (!fileName.startsWith(".")) {
val fontName = fileName.split('.')[0]
if (fontDef.main.keys.none { it == fontName }) {
Log.i("Deleting font file: $fileName")
file.delete()
}
}
}
// These files should be downloaded by the JS side, but oh well
CoroutineScope(Dispatchers.IO).launch {
fontDef.main.keys.map { name ->
async {
val url = fontDef.main.getValue(name)
try {
Log.i("Downloading $name from $url")
val file = File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}")
if (file.exists()) return@async
val client = HttpClient(CIO) {
install(UserAgent) { agent = Constants.USER_AGENT }
}
val response: HttpResponse = client.get(url)
if (response.status == HttpStatusCode.OK)
file.writeBytes(response.body())
return@async
} catch (e: Throwable) {
Log.e("Failed to download fonts ($name from $url)", e)
}
}
}.awaitAll()
}
return@with
}
private fun createAssetTypefaceWithFallbacks(
@ -152,13 +137,11 @@ class FontsModule: Module() {
val family = FontFamily.Builder(font).build()
fontFamilies.add(family)
}
} catch (e: Throwable) {
// ignore
}
} catch (_: Throwable) {}
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
for (fileExtension in FILE_EXTENSIONS) {
val fileName = java.lang.StringBuilder()
val fileName = StringBuilder()
.append(fontRootPath)
.append(fontFamilyName)
.append(fileExtension)
@ -168,10 +151,10 @@ class FontsModule: Module() {
val font = builder.build()
val family = FontFamily.Builder(font).build()
fontFamilies.add(family)
} catch (e: java.lang.RuntimeException) {
} catch (_: RuntimeException) {
// If the typeface asset does not exist, try another extension.
continue
} catch (e: IOException) {
} catch (_: IOException) {
// If the font asset does not exist, try another extension.
continue
}
@ -180,9 +163,8 @@ class FontsModule: Module() {
}
// If there's some problem constructing fonts, fall back to the default behavior.
if (fontFamilies.size == 0) {
return createAssetTypeface(fontFamilyNames[0], style, assetManager)
}
if (fontFamilies.isEmpty()) return createAssetTypeface(fontFamilyNames[0], style, assetManager)
val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0])
for (i in 1 until fontFamilies.size) {
fallbackBuilder.addCustomFallback(fontFamilies[i])
@ -193,11 +175,11 @@ class FontsModule: Module() {
}
private fun createAssetTypeface(
fontFamilyName_: String, style: Int, assetManager: AssetManager
fontFamilyName: String, style: Int, assetManager: AssetManager
): Typeface? {
// 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_
var fontFamilyName: String = fontFamilyName
val fontFamilyNames =
fontFamilyName.split(",".toRegex())
.dropLastWhile { it.isEmpty() }
@ -226,15 +208,13 @@ class FontsModule: Module() {
if (!file.exists()) throw Exception()
return Typeface.createFromFile(file.absolutePath)
}
} catch (e: Throwable) {
// ignore
}
} 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 = java.lang.StringBuilder()
val fileName = StringBuilder()
.append(fontRootPath)
.append(fontFamilyName)
.append(extension)
@ -246,7 +226,7 @@ class FontsModule: Module() {
Typeface.createFromFile(fileName)
else
Typeface.createFromAsset(assetManager, fileName)
} catch (e: java.lang.RuntimeException) {
} catch (_: RuntimeException) {
// If the typeface asset does not exist, try another extension.
continue
}

View file

@ -1,14 +1,16 @@
package io.github.revenge.xposed
package io.github.revenge.xposed.modules
import android.app.AlertDialog
import android.app.AndroidAppHelper
import android.content.Context
import android.content.Intent
import android.util.Log
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 java.io.File
import kotlin.system.exitProcess
@ -59,7 +61,7 @@ class LogBoxModule: Module() {
}
}
} catch (ex: Exception) {
Log.e("Revenge", "Failed to show dev options dialog: $ex")
Utils.Log.e("Failed to show dev options dialog: $ex")
alertDialog = null
}
@ -73,9 +75,7 @@ class LogBoxModule: Module() {
XposedBridge.hookMethod(
handleReloadJSMethod,
object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
reloadApp()
}
override fun beforeHookedMethod(param: MethodHookParam) = reloadApp()
}
)
@ -91,10 +91,8 @@ class LogBoxModule: Module() {
reloadApp()
}
1 -> {
val bundleFile = File(packageParam.appInfo.dataDir, "cache/pyoncord/bundle.js")
if (bundleFile.exists()) {
bundleFile.delete()
}
val bundleFile = File(packageParam.appInfo.dataDir, "${Constants.CACHE_DIR}/${Constants.BUNDLE_FILE}")
if (bundleFile.exists()) bundleFile.delete()
reloadApp()
}

View file

@ -1,9 +1,11 @@
package io.github.revenge.xposed
package io.github.revenge.xposed.modules
import android.R.color
import android.app.AndroidAppHelper
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.*
@ -20,7 +22,7 @@ class SysColorsModule : Module() {
private lateinit var context: Context
private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
override fun buildJson(builder: JsonObjectBuilder) {
override fun buildPayload(builder: JsonObjectBuilder) {
context = AndroidAppHelper.currentApplication()
val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2")
val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
@ -33,7 +35,7 @@ class SysColorsModule : Module() {
val colorName = "system_" + accent + "_" + shade
val colorResourceId = runCatching {
android.R.color::class.java.getField(colorName).getInt(null)
color::class.java.getField(colorName).getInt(null)
}.getOrElse { 0 }
add(convertToColor(colorResourceId))

View file

@ -1,18 +1,18 @@
package io.github.revenge.xposed
package io.github.revenge.xposed.modules
import android.content.Context
import android.graphics.Color
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 kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import kotlinx.serialization.json.*
import androidx.core.graphics.toColorInt
import io.github.revenge.xposed.Constants
import io.github.revenge.xposed.Module
import io.github.revenge.xposed.Utils.Companion.JSON
@Serializable
data class Author(
@ -41,17 +41,21 @@ class ThemeModule : Module() {
private var theme: Theme? = null
private val rawColorMap = mutableMapOf<String, Int>()
val THEME_FILE = "current-theme.json"
@ExperimentalSerializationApi
override fun buildJson(builder: JsonObjectBuilder) {
override fun buildPayload(builder: JsonObjectBuilder) {
builder.apply {
put("hasThemeSupport", true)
if (theme != null)
put("storedTheme", Json.encodeToJsonElement<Theme>(theme!!))
put("storedTheme", JSON.encodeToJsonElement<Theme>(theme!!))
else
put("storedTheme", null)
}
}
private fun String.fromScreamingSnakeToCamelCase() = this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } }
override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) {
param = packageParam
theme = getTheme()
@ -66,21 +70,13 @@ class ThemeModule : Module() {
}
private fun getTheme(): Theme? {
val filesDir = File(param.appInfo.dataDir, "files").apply { mkdirs() }
val pyonDir = File(filesDir, "pyoncord").apply { mkdirs() }
val themeFile = File(pyonDir, "current-theme.json")
val legacyThemeFile = File(filesDir, "vendetta_theme.json")
if (legacyThemeFile.isValidish() && !themeFile.isValidish()) {
legacyThemeFile.copyTo(themeFile, overwrite = true)
}
val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}")
if (!themeFile.isValidish()) return null
return try {
val themeText = themeFile.readText()
Json { ignoreUnknownKeys = true }.decodeFromString<Theme>(themeText)
} catch (e: Exception) { null }
JSON.decodeFromString<Theme>(themeText)
} catch (_: Exception) { null }
}
fun hookTheme() {
@ -99,7 +95,7 @@ class ThemeModule : Module() {
// Apply semanticColors
theme.data.semanticColors?.forEach { (key, value) ->
// TEXT_NORMAL -> getTextNormal
val methodName = "get${key.split("_").joinToString("") { it.lowercase().replaceFirstChar { it.uppercase() } }}"
val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
value.forEachIndexed { index, v ->
when (index) {
0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v))
@ -144,22 +140,19 @@ class ThemeModule : Module() {
// Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy
val alpha = hexString.substring(7, 9)
val rrggbb = hexString.substring(1, 7)
Color.parseColor("#$alpha$rrggbb")
} else Color.parseColor(hexString)
"#$alpha$rrggbb".toColorInt()
} else hexString.toColorInt()
}
private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
try {
themeClass.getDeclaredMethod(methodName).let { method ->
// Log.i("Hooking $methodName -> ${themeValue.toString(16)}")
XposedBridge.hookMethod(method, object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
param.result = themeValue
}
})
}
} catch (ex: NoSuchMethodException) {
// do nothing
}
} catch (_: NoSuchMethodException) {}
}
}