diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts
new file mode 100644
index 0000000..2b9856c
--- /dev/null
+++ b/app/app/build.gradle.kts
@@ -0,0 +1,51 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ kotlin("plugin.serialization") version "2.1.10"
+}
+
+android {
+ namespace = "GoonXposed.xposed"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "GoonXposed.xposed"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = 1220
+ versionName = "1.2.2"
+ }
+
+ buildTypes {
+ debug {
+ isDebuggable = true
+ isMinifyEnabled = false
+ applicationIdSuffix = ".debug"
+ }
+ release {
+ isDebuggable = false
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+dependencies {
+ compileOnly("de.robv.android.xposed:api:82")
+
+ implementation("androidx.core:core-ktx:1.17.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
+ implementation("io.ktor:ktor-client-core:3.3.1")
+ implementation("io.ktor:ktor-client-cio:3.3.1")
+ implementation("com.google.android.material:material:1.12.0")
+}
diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..40f8598
--- /dev/null
+++ b/app/app/src/main/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/main/assets/xposed_init b/app/app/src/main/assets/xposed_init
new file mode 100644
index 0000000..d1f1c51
--- /dev/null
+++ b/app/app/src/main/assets/xposed_init
@@ -0,0 +1 @@
+GoonXposed.xposed.Main
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Constants.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Constants.kt
new file mode 100644
index 0000000..1c91b08
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Constants.kt
@@ -0,0 +1,19 @@
+package GoonXposed.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 MAIN_SCRIPT_FILE = "bundle.js"
+
+
+ const val LOG_TAG = "Goon"
+
+ const val LOADER_NAME = "GoonXposed"
+
+ const val USER_AGENT = "GoonXposed"
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Main.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Main.kt
new file mode 100644
index 0000000..f3b328e
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Main.kt
@@ -0,0 +1,107 @@
+package GoonXposed.xposed
+
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.os.Bundle
+import de.robv.android.xposed.IXposedHookLoadPackage
+import de.robv.android.xposed.IXposedHookZygoteInit
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Utils.Log
+import GoonXposed.xposed.modules.*
+import GoonXposed.xposed.modules.appearance.FontsModule
+import GoonXposed.xposed.modules.appearance.SysColorsModule
+import GoonXposed.xposed.modules.appearance.ThemesModule
+import GoonXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule
+import GoonXposed.xposed.modules.bridge.BridgeModule
+import GoonXposed.xposed.modules.no_track.BlockCrashReportingModule
+import GoonXposed.xposed.modules.no_track.BlockDeepLinksTrackingModule
+import kotlinx.coroutines.CompletableDeferred
+
+object HookStateHolder {
+ /**
+ * Whether all hooks are completed, and we are ready to load the JS bundle.
+ */
+ val readyDeferred = CompletableDeferred()
+
+ /**
+ * Whether we have successfully received a [Context] yet.
+ * Sometimes the app process is recreated and Xposed hooks way too late for us to get [Context] from [ContextWrapper.attachBaseContext].
+ * But since Xposed hooks before [Activity.onCreate], we can still get it from there and still initialize properly.
+ */
+ @Volatile
+ var gotContext = false
+}
+
+class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
+ private var hooked = false
+ private val modules = mutableListOf(
+ HookScriptLoaderModule,
+ BridgeModule,
+ AdditionalBridgeMethodsModule,
+ PluginsModule(),
+ UpdaterModule,
+ FixResourcesModule,
+ BlockDeepLinksTrackingModule,
+ BlockCrashReportingModule,
+ LogBoxModule,
+ CacheModule,
+ FontsModule,
+ ThemesModule,
+ SysColorsModule
+ )
+
+ init {
+ modules += PayloadGlobalModule(modules)
+ }
+
+ override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
+ for (module in modules) module.onInit(startupParam)
+ }
+
+ override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with(param) {
+ if (hooked) return
+
+ val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY)
+
+ ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) {
+ after {
+ val ctx = args[0] as Context
+ HookStateHolder.gotContext = true
+ Log.i("Received Context")
+ this@Main.onContext(ctx)
+ }
+ }
+
+ reactActivity.hookMethod("onCreate", Bundle::class.java) {
+ after {
+ val act = thisObject as Activity
+ Log.i("Received Activity")
+
+ if (!HookStateHolder.gotContext) {
+ Log.w("Activity created before we got Context, process may have been recreated!")
+ this@Main.onContext(act.applicationContext)
+ }
+
+ this@Main.onActivity(act)
+ HookStateHolder.readyDeferred.complete(Unit)
+ }
+ }
+
+ this@Main.onLoad(param)
+
+ hooked = true
+ }
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
+ for (module in modules) module.onLoad(packageParam)
+ }
+
+ override fun onContext(context: Context) {
+ for (module in modules) module.onContext(context)
+ }
+
+ override fun onActivity(activity: Activity) {
+ for (module in modules) module.onActivity(activity)
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Module.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Module.kt
new file mode 100644
index 0000000..41f878f
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Module.kt
@@ -0,0 +1,183 @@
+package GoonXposed.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 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 {
+ /**
+ * 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) {}
+
+ /**
+ * 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()
+ this.mkdirs()
+ }
+
+ protected fun File.asFile() {
+ if (!this.isFile) this.deleteRecursively()
+ }
+
+ protected fun Class<*>.method(
+ name: String, vararg params: Class<*>?
+ ): Method = getDeclaredMethod(name, *params).apply {
+ isAccessible = true
+ }
+
+ 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())
+
+ 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, 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)
+ }
+ }
+ }
+ }
+
+ /**
+ * 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 proceed Function that calls the super method.
+ */
+ protected class HookScope internal constructor(
+ 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() = proceed(param)
+
+ val thisObject: Any? get() = param.thisObject
+
+ val args: Array 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
+ }
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Utils.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Utils.kt
new file mode 100644
index 0000000..f92abbf
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/Utils.kt
@@ -0,0 +1,58 @@
+package GoonXposed.xposed
+
+import android.app.AlertDialog
+import android.app.AndroidAppHelper
+import android.content.Context
+import android.content.Intent
+import GoonXposed.xposed.modules.UpdaterModule
+import kotlinx.serialization.json.Json
+import java.io.File
+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)
+ }
+
+ fun showRecoveryAlert(context: Context) {
+ AlertDialog.Builder(context).setTitle("GoonCord Recovery Options")
+ .setItems(arrayOf("Reload", "Delete Script", "Reset Loader Config")) { _, which ->
+ when (which) {
+ 0 -> {
+ reloadApp()
+ }
+
+ 1 -> {
+ val bundleFile = File(
+ context.dataDir, "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
+ )
+
+ if (bundleFile.exists()) bundleFile.delete()
+
+ reloadApp()
+ }
+
+ 2 -> {
+ UpdaterModule.resetLoaderConfig(context)
+ reloadApp()
+ }
+ }
+ }.show()
+ }
+ }
+
+ 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)
+ 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)
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/CacheModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/CacheModule.kt
new file mode 100644
index 0000000..debbbc8
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/CacheModule.kt
@@ -0,0 +1,270 @@
+package GoonXposed.xposed.modules
+
+import android.content.Context
+import android.util.AtomicFile
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Log
+import GoonXposed.xposed.modules.bridge.BridgeModule
+import java.io.*
+
+/**
+ * A module for caching versioned modules finds and blacklist, and assets mappings.
+ *
+ * See [ModulesCache] and [AssetsCache] for the cache structure.
+ *
+ * ## Methods
+ *
+ * ### Modules cache
+ *
+ * - `Shiggy.caches.modules.read() : { blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }, version: number } | null`
+ * - Reads the modules cache. Returns `null` if the cache file does not exist or is invalid.
+ *
+ * - `Shiggy.caches.modules.write(blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }) : void`
+ * - Writes the modules cache.
+ *
+ * ### Assets cache
+ *
+ * - `Shiggy.caches.assets.read() : { data: { [name: string]: { [type: string]: number } }, version: number } | null`
+ * - Reads the assets cache. Returns `null` if the cache file does not exist or is invalid.
+ *
+ * - `Shiggy.caches.assets.write(data: { [name: string]: { [type: string]: number } }) : void`
+ * - Writes the assets cache.
+ *
+ * The caches are versioned and tied to the app version code. When the app is updated,
+ * the caches are invalidated and new cache files are created.
+ */
+object CacheModule : Module() {
+
+ private const val CACHE_DIR = "revenge"
+
+ private const val MODULES_CACHE_PREFIX = "modules"
+ private const val ASSETS_CACHE_PREFIX = "assets"
+
+
+ private lateinit var modulesCache: ModulesCache
+ private lateinit var assetsCache: AssetsCache
+
+ override fun onContext(context: Context) = with(context) {
+ val revengeCacheDir = File(cacheDir, CACHE_DIR).apply { asDir() }
+ val (_, _, _, versionCode) = getAppInfo()
+
+ val modulesCacheFile = File(
+ revengeCacheDir, "$MODULES_CACHE_PREFIX.$versionCode"
+ ).apply { asFile() }
+
+ val assetsCacheFile = File(
+ revengeCacheDir, "$ASSETS_CACHE_PREFIX.$versionCode"
+ ).apply { asFile() }
+
+ BridgeModule.registerMethod("revenge.caches.modules.read") {
+ if (::modulesCache.isInitialized) modulesCache.toMap() else ModulesCache.loadFromFileOrNull(modulesCacheFile)
+ ?.let {
+ modulesCache = it
+ it.toMap()
+ }
+ }
+
+ BridgeModule.registerMethod("revenge.caches.modules.write") {
+ val (blacklist, finds) = it
+ @Suppress("UNCHECKED_CAST")
+ modulesCache = ModulesCache(
+ blacklist as ArrayList, finds as HashMap?>
+ ).apply { saveToFile(modulesCacheFile) }
+ Log.i("Modules cache saved: ${modulesCacheFile.absolutePath} (blacklisted: ${blacklist.size}, finds: ${finds.size})")
+ }
+
+ BridgeModule.registerMethod("revenge.caches.assets.read") {
+ if (::assetsCache.isInitialized) assetsCache.toMap() else AssetsCache.loadFromFileOrNull(assetsCacheFile)
+ ?.let {
+ assetsCache = it
+ it.toMap()
+ }
+ }
+
+ BridgeModule.registerMethod("revenge.caches.assets.write") {
+ val (data) = it
+ @Suppress("UNCHECKED_CAST")
+ assetsCache =
+ AssetsCache(data as HashMap>).apply { saveToFile(assetsCacheFile) }
+ Log.i("Assets cache saved: ${assetsCacheFile.absolutePath} (count: ${data.size})")
+ }
+ }
+}
+
+private class CacheVersionMismatchException(expected: Int, actual: Int) :
+ Throwable("Expected cache version: $expected, but got version: $actual")
+
+data class ModulesCache(
+ val blacklist: List, val finds: Map?>
+) {
+ fun saveToFile(file: File) {
+ val atomic = AtomicFile(file)
+ var fos: FileOutputStream? = null
+ try {
+ fos = atomic.startWrite()
+ val out = DataOutputStream(BufferedOutputStream(fos))
+
+ out.writeInt(VERSION)
+
+ // Write blacklist
+ out.writeInt(blacklist.size)
+ for (num in blacklist) out.writeInt(num.toInt())
+
+ // Write finds
+ out.writeInt(finds.size)
+ for ((filter, matches) in finds) {
+ out.writeUTF(filter)
+ if (matches == null) {
+ out.writeBoolean(false) // null marker
+ } else {
+ out.writeBoolean(true) // not null
+ out.writeInt(matches.size)
+ for ((id, flag) in matches) {
+ out.writeInt(id.toInt())
+ out.writeInt(flag.toInt())
+ }
+ }
+ }
+
+ out.flush()
+ atomic.finishWrite(fos)
+ } catch (t: Throwable) {
+ if (fos != null) atomic.failWrite(fos)
+ throw t
+ }
+ }
+
+ fun toMap(): Map {
+ val findsMap = finds.mapValues { (_, matches) -> matches?.map { (k, v) -> k to v }?.toMap() }
+
+ return mapOf("blacklist" to blacklist, "finds" to findsMap, "version" to VERSION)
+ }
+
+ companion object {
+ const val VERSION = 3
+
+ fun loadFromFileOrNull(file: File): ModulesCache? {
+ if (!file.exists() || file.length() <= 0L) return null
+ val atomic = AtomicFile(file)
+ try {
+ DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
+ val version = input.readInt()
+ if (version != VERSION) throw CacheVersionMismatchException(VERSION, version)
+
+ val blacklistSize = input.readInt()
+ val blacklist = MutableList(blacklistSize) { input.readInt().toDouble() }
+
+ val findsSize = input.readInt()
+ val finds = mutableMapOf?>()
+ repeat(findsSize) {
+ val filter = input.readUTF()
+ val hasMatches = input.readBoolean()
+ if (!hasMatches) {
+ finds[filter] = null
+ } else {
+ val mapSize = input.readInt()
+ val matches = mutableMapOf()
+ repeat(mapSize) {
+ val id = input.readInt().toString()
+ val flag = input.readInt().toDouble()
+ matches[id] = flag
+ }
+ finds[filter] = matches
+ }
+ }
+
+ return ModulesCache(blacklist, finds)
+ }
+ } catch (e: CacheVersionMismatchException) {
+ Log.i("Modules cache version mismatch: ${e.message}")
+ } catch (e: EOFException) {
+ Log.e("Modules cache corrupt: ${e.message}")
+ } catch (e: IOException) {
+ Log.e("Failed to read modules cache: ${e.message}")
+ }
+ runCatching { file.delete() }
+ return null
+ }
+ }
+}
+
+data class AssetsCache(
+ val data: Map>
+) {
+ fun saveToFile(file: File) {
+ val atomic = AtomicFile(file)
+ var fos: FileOutputStream? = null
+ try {
+ fos = atomic.startWrite()
+ val out = DataOutputStream(BufferedOutputStream(fos))
+
+ out.writeInt(VERSION)
+
+ out.writeInt(data.size)
+ for ((name, mappings) in data) {
+ out.writeUTF(name)
+ out.writeInt(mappings.size)
+ for ((type, id) in mappings) {
+ out.writeUTF(type)
+ out.writeInt(id.toInt())
+ }
+ }
+
+ out.flush()
+ atomic.finishWrite(fos)
+ } catch (t: Throwable) {
+ if (fos != null) atomic.failWrite(fos)
+ throw t
+ }
+ }
+
+ fun toMap(): Map {
+ return mapOf(
+ "data" to data.mapValues { (_, mappings) ->
+ mappings.map { (type, id) -> type to id }.toMap()
+ }, "version" to VERSION
+ )
+ }
+
+ companion object {
+ const val VERSION = 2
+
+ fun loadFromFileOrNull(file: File): AssetsCache? {
+ if (!file.exists() || file.length() <= 0L) return null
+ val atomic = AtomicFile(file)
+ try {
+ DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
+ val version = input.readInt()
+ if (version != VERSION) throw CacheVersionMismatchException(VERSION, version)
+
+ val dataSize = input.readInt()
+ val data = mutableMapOf>()
+
+ repeat(dataSize) {
+ val name = input.readUTF()
+ val mappingsSize = input.readInt()
+ val mappings = mutableMapOf()
+
+ repeat(mappingsSize) {
+ val type = input.readUTF()
+ val id = input.readInt().toDouble()
+ mappings[type] = id
+ }
+
+ data[name] = mappings
+ }
+
+ return AssetsCache(data)
+ }
+ } catch (e: CacheVersionMismatchException) {
+ Log.i("Assets cache version mismatch: ${e.message}")
+ } catch (e: EOFException) {
+ Log.e("Assets cache corrupt: ${e.message}")
+ } catch (e: IOException) {
+ Log.e("Failed to read assets cache: ${e.message}")
+ }
+ runCatching { file.delete() }
+ return null
+ }
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/FixResourcesModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/FixResourcesModule.kt
new file mode 100644
index 0000000..c6f39fe
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/FixResourcesModule.kt
@@ -0,0 +1,23 @@
+package GoonXposed.xposed.modules
+
+import android.content.res.Resources
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE
+import GoonXposed.xposed.Module
+
+/**
+ * Hooks [Resources.getIdentifier] to fix resource package name mismatch.
+ */
+object 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
+ }
+ }
+ }
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/HookScriptLoaderModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/HookScriptLoaderModule.kt
new file mode 100644
index 0000000..a1dbf30
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/HookScriptLoaderModule.kt
@@ -0,0 +1,121 @@
+package GoonXposed.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 GoonXposed.xposed.Constants
+import GoonXposed.xposed.HookStateHolder
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Log
+import GoonXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.runBlocking
+import java.io.File
+import java.lang.reflect.Method
+
+/**
+ * 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://Shiggy.bundle` from the module's assets.
+ */
+object HookScriptLoaderModule : Module() {
+ private lateinit var preloadsDir: File
+ private lateinit var mainScript: File
+
+ private lateinit var modulePath: String
+ private lateinit var resources: XModuleResources
+
+ 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 { asDir() }
+ mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() }
+
+ listOf(
+ "com.facebook.react.runtime.ReactInstance\$loadJSBundle$1",
+ "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<*>) = runCatching {
+ 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: ${args[1]} (sync: ${args[2]})")
+ runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
+ }
+ }
+
+ loadScriptFromFile.hook {
+ before {
+ Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
+ runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
+ }
+ }
+ }.onFailure {
+ Log.e("Failed to hook script loading methods in ${instance.name}:", it)
+ }
+
+ private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) {
+ Log.i("Running custom scripts...")
+
+ runBlocking {
+ val ready = async { HookStateHolder.readyDeferred.join() }
+ val download = async { UpdaterModule.downloadScript().join() }
+
+ awaitAll(ready, download)
+ }
+
+ 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://Shiggy.bundle", loadSynchronously)
+ )
+ }
+ } catch (e: Throwable) {
+ Log.e("Unable to run scripts:", e)
+ }
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/LogBoxModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/LogBoxModule.kt
new file mode 100644
index 0000000..44d56cb
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/LogBoxModule.kt
@@ -0,0 +1,1051 @@
+package GoonXposed.xposed.modules
+
+import android.animation.AnimatorSet
+import android.animation.ArgbEvaluator
+import android.animation.ValueAnimator
+import android.app.AlertDialog
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.DecelerateInterpolator
+import android.widget.EditText
+import android.widget.LinearLayout
+import android.widget.TextView
+import android.widget.Toast
+import androidx.core.view.setPadding
+import de.robv.android.xposed.XC_MethodReplacement
+import de.robv.android.xposed.XposedBridge
+import de.robv.android.xposed.XposedHelpers
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Companion.reloadApp
+import GoonXposed.xposed.Utils.Log
+import kotlinx.coroutines.*
+import org.json.JSONObject
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+
+object LogBoxModule : Module() {
+ lateinit var packageParam: XC_LoadPackage.LoadPackageParam
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ private var contextForMenu: Context? = null
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
+ this@LogBoxModule.packageParam = packageParam
+
+ try {
+ val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
+ val getUseDeveloperSupportMethod =
+ dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
+
+ getUseDeveloperSupportMethod.hook {
+ before {
+ result = true
+ }
+ }
+ Log.e("Successfully hooked DCDReactNativeHost")
+ } catch (e: Exception) {
+ Log.e("Failed to hook DCDReactNativeHost: ${e.message}")
+ }
+
+ return@with
+ }
+
+ override fun onContext(context: Context) {
+ try {
+ Log.e("onContext called with context: $context")
+ contextForMenu = context
+
+ val possibleClasses = listOf(
+ "com.facebook.react.devsupport.BridgeDevSupportManager",
+ "com.facebook.react.devsupport.BridgelessDevSupportManager",
+ "com.facebook.react.devsupport.DevSupportManagerImpl",
+ "com.facebook.react.devsupport.DevSupportManagerBase",
+ "com.facebook.react.devsupport.DefaultDevSupportManager"
+ )
+
+ var foundAny = false
+ possibleClasses.forEach { className ->
+ try {
+ val clazz = packageParam.classLoader.loadClass(className)
+ Log.e("Found class: $className")
+ hookDevSupportManager(clazz, context)
+ foundAny = true
+ } catch (e: Exception) {
+ Log.e("Class not found: $className - ${e.message}")
+ }
+ }
+
+ if (!foundAny) {
+ tryFindDevSupportClasses(context)
+ }
+ } catch (e: Exception) {
+ }
+ }
+
+ private fun tryFindDevSupportClasses(context: Context) {
+ try {
+ val dexFile = packageParam.classLoader.javaClass.getDeclaredField("pathList")
+ dexFile.isAccessible = true
+ Log.e("Searching for DevSupport classes in classloader...")
+ } catch (e: Exception) {
+ Log.e("Could not search for classes: ${e.message}")
+ }
+ }
+
+ private fun hookDevSupportManager(clazz: Class<*>, context: Context) {
+ Log.e("Attempting to hook ${clazz.name}")
+
+ // List all methods to see what's available
+ Log.e("Available methods in ${clazz.simpleName}:")
+ clazz.methods.forEach { method ->
+ if (method.name.contains("Dev") || method.name.contains("Reload") || method.name.contains("Options")) {
+ Log.e(" - ${method.name}")
+ }
+ }
+
+ try {
+ try {
+ val handleReloadJSMethod = clazz.methods.firstOrNull { it.name == "handleReloadJS" }
+ if (handleReloadJSMethod != null) {
+ XposedBridge.hookMethod(handleReloadJSMethod, object : XC_MethodReplacement() {
+ override fun replaceHookedMethod(param: MethodHookParam): Any? {
+ Log.e("handleReloadJS called - reloading app")
+ reloadApp()
+ return null
+ }
+ })
+ }
+ } catch (e: Exception) {
+ Log.e("Failed to hook handleReloadJS: ${e.message}")
+ }
+
+ try {
+ val showDevOptionsDialogMethod = clazz.methods.firstOrNull { it.name == "showDevOptionsDialog" }
+ if (showDevOptionsDialogMethod != null) {
+ XposedBridge.hookMethod(showDevOptionsDialogMethod, object : XC_MethodReplacement() {
+ override fun replaceHookedMethod(param: MethodHookParam): Any? {
+ try {
+ var activityContext: Context? = null
+ try {
+ activityContext = getContextFromDevSupport(clazz, param.thisObject)
+ if (activityContext != null) {
+ Log.e("Successfully got context from DevSupport")
+ }
+ } catch (e: Exception) {
+ Log.e("Failed to get context from DevSupport (non-fatal): ${e.message}")
+ }
+
+ val finalContext = activityContext ?: contextForMenu ?: context
+ Log.e("Using context: $finalContext (type: ${finalContext.javaClass.name})")
+
+ showRecoveryMenu(finalContext)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ return null
+ }
+ })
+ }
+ } catch (e: Exception) {
+ }
+ } catch (e: Exception) {
+ }
+ }
+
+ private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? {
+ if (instance == null) {
+ Log.e("getContextFromDevSupport: instance is null")
+ return null
+ }
+
+ return try {
+
+ // what if we just did this and searched for literally everything
+ val helpers = listOf(
+ "mReactInstanceDevHelper",
+ "reactInstanceDevHelper",
+ "mReactInstanceManager",
+ "mApplicationContext"
+ )
+
+ for (helperName in helpers) {
+ try {
+ Log.e("Trying field: $helperName")
+ val helperField = XposedHelpers.findFieldIfExists(clazz, helperName)
+ if (helperField == null) {
+ Log.e("Field $helperName not found, skipping")
+ continue
+ }
+
+ val helper = helperField.get(instance)
+ if (helper == null) {
+ Log.e("Field $helperName is null, skipping")
+ continue
+ }
+
+ if (helper is Context) {
+ Log.e("Field $helperName is a Context, returning it")
+ return helper
+ }
+
+ val getCurrentActivityMethod = helper.javaClass.methods.firstOrNull {
+ it.name == "getCurrentActivity"
+ }
+
+ if (getCurrentActivityMethod != null) {
+ val ctx = getCurrentActivityMethod.invoke(helper) as? Context
+ if (ctx != null) {
+ Log.e("Got context from $helperName.getCurrentActivity()")
+ return ctx
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("Error trying $helperName: ${e.message}")
+ }
+ }
+
+ Log.e("Could not get context from DevSupport object using any method")
+ null
+ } catch (e: Exception) {
+ Log.e("Failed to get context (outer catch): ${e.message}")
+ null
+ }
+ }
+
+ // Material 3 Design Helper Functions
+ private fun dpToPx(context: Context, dp: Int): Int {
+ return TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ dp.toFloat(),
+ context.resources.displayMetrics
+ ).toInt()
+ }
+
+ private fun isDarkMode(context: Context): Boolean {
+ return (context.resources.configuration.uiMode and
+ android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
+ android.content.res.Configuration.UI_MODE_NIGHT_YES
+ }
+
+ private fun getM3Colors(context: Context): M3Colors {
+ val isDark = isDarkMode(context)
+ return if (isDark) {
+ M3Colors(
+ surface = Color.parseColor("#1C1B1F"),
+ surfaceVariant = Color.parseColor("#49454F"),
+ onSurface = Color.parseColor("#E6E1E5"),
+ onSurfaceVariant = Color.parseColor("#CAC4D0"),
+ primary = Color.parseColor("#D0BCFF"),
+ onPrimary = Color.parseColor("#381E72"),
+ primaryContainer = Color.parseColor("#4F378B"),
+ onPrimaryContainer = Color.parseColor("#EADDFF"),
+ secondaryContainer = Color.parseColor("#4A4458"),
+ onSecondaryContainer = Color.parseColor("#E8DEF8"),
+ error = Color.parseColor("#F2B8B5"),
+ onError = Color.parseColor("#601410")
+ )
+ } else {
+ M3Colors(
+ surface = Color.parseColor("#FFFBFE"),
+ surfaceVariant = Color.parseColor("#E7E0EC"),
+ onSurface = Color.parseColor("#1C1B1F"),
+ onSurfaceVariant = Color.parseColor("#49454F"),
+ primary = Color.parseColor("#6750A4"),
+ onPrimary = Color.parseColor("#FFFFFF"),
+ primaryContainer = Color.parseColor("#EADDFF"),
+ onPrimaryContainer = Color.parseColor("#21005D"),
+ secondaryContainer = Color.parseColor("#E8DEF8"),
+ onSecondaryContainer = Color.parseColor("#1D192B"),
+ error = Color.parseColor("#B3261E"),
+ onError = Color.parseColor("#FFFFFF")
+ )
+ }
+ }
+
+ private data class M3Colors(
+ val surface: Int,
+ val surfaceVariant: Int,
+ val onSurface: Int,
+ val onSurfaceVariant: Int,
+ val primary: Int,
+ val onPrimary: Int,
+ val primaryContainer: Int,
+ val onPrimaryContainer: Int,
+ val secondaryContainer: Int,
+ val onSecondaryContainer: Int,
+ val error: Int,
+ val onError: Int
+ )
+
+ private fun createM3Background(context: Context, color: Int, cornerRadius: Float): GradientDrawable {
+ return GradientDrawable().apply {
+ setColor(color)
+ setCornerRadius(dpToPx(context, cornerRadius.toInt()).toFloat())
+ }
+ }
+
+ private fun createM3Button(context: Context, text: String, colors: M3Colors, onClick: () -> Unit): View {
+ return LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER
+ background = createM3Background(context, colors.primaryContainer, 20f)
+ setPadding(
+ dpToPx(context, 24),
+ dpToPx(context, 10),
+ dpToPx(context, 24),
+ dpToPx(context, 10)
+ )
+ clipToOutline = true
+ isClickable = true
+ isFocusable = true
+
+ val params = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ dpToPx(context, 40)
+ ).apply {
+ setMargins(0, dpToPx(context, 8), 0, 0)
+ }
+ layoutParams = params
+
+ addView(TextView(context).apply {
+ this.text = text
+ setTextColor(colors.onPrimaryContainer)
+ textSize = 14f
+ typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
+ gravity = Gravity.CENTER
+ })
+
+ // Create a custom ripple effect that stays within bounds
+ val rippleDrawable = android.graphics.drawable.RippleDrawable(
+ ColorStateList.valueOf(Color.argb(30, 255, 255, 255)),
+ createM3Background(context, colors.primaryContainer, 20f),
+ null
+ )
+ background = rippleDrawable
+
+ setOnClickListener { onClick() }
+ }
+ }
+
+ private fun showRecoveryMenu(context: Context) {
+ Log.e("showRecoveryMenu called with context: $context")
+ try {
+ val colors = getM3Colors(context)
+ lateinit var dialog: AlertDialog
+
+ val container = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(
+ dpToPx(context, 24),
+ dpToPx(context, 24),
+ dpToPx(context, 24),
+ dpToPx(context, 24)
+ )
+ background = createM3Background(context, colors.surface, 28f)
+ }
+
+ // Title
+ val titleView = TextView(context).apply {
+ text = "ShiggyXposed Recovery"
+ textSize = 22f
+ setTextColor(colors.onSurface)
+ typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
+ setPadding(0, 0, 0, dpToPx(context, 16))
+ gravity = Gravity.CENTER
+ }
+ container.addView(titleView)
+
+ // Subtitle
+ val subtitleView = TextView(context).apply {
+ text = "Select an option to continue"
+ textSize = 14f
+ setTextColor(colors.onSurfaceVariant)
+ typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
+ setPadding(0, 0, 0, dpToPx(context, 24))
+ gravity = Gravity.CENTER
+ }
+ container.addView(subtitleView)
+
+ // Safe Mode Button
+ val safeModeText = if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode"
+ container.addView(createM3Button(context, safeModeText, colors) {
+ dialog.dismiss()
+ handleMenuSelection(context, 0)
+ })
+
+ // Refetch Bundle Button
+ container.addView(createM3Button(context, "Refetch Bundle", colors) {
+ dialog.dismiss()
+ handleMenuSelection(context, 1)
+ })
+
+ // Load Custom Bundle Button
+ container.addView(createM3Button(context, "Load Custom Bundle", colors) {
+ dialog.dismiss()
+ handleMenuSelection(context, 2)
+ })
+
+ // Reload App Button
+ container.addView(createM3Button(context, "Reload App", colors) {
+ dialog.dismiss()
+ handleMenuSelection(context, 3)
+ })
+
+ // Clear Cache & Reset Button
+ container.addView(createM3Button(context, "Clear Cache & Reset", colors) {
+ dialog.dismiss()
+ handleMenuSelection(context, 4)
+ })
+
+ dialog = AlertDialog.Builder(context)
+ .setView(container)
+ .create()
+
+ dialog.window?.setBackgroundDrawable(
+ createM3Background(context, Color.TRANSPARENT, 28f)
+ )
+
+ dialog.show()
+ Log.e("Recovery menu shown successfully")
+ } catch (e: Exception) {
+ Log.e("Error showing recovery menu: ${e.message}", e)
+ throw e
+ }
+ }
+
+ private fun handleMenuSelection(context: Context, index: Int) {
+ when (index) {
+ 0 -> toggleSafeMode(context)
+ 1 -> showConfirmAction(
+ context, "Refetch Bundle",
+ "This will download the latest bundle from Github."
+ ) { refetchBundle(context) }
+
+ 2 -> showCustomBundleDialog(context)
+ 3 -> reloadApp()
+ 4 -> showConfirmAction(
+ context, "Clear Cache & Reset",
+ "This will clear all cached bundles and reset to default settings."
+ ) { clearCacheAndReset(context) }
+ }
+ }
+
+ private fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) {
+ val colors = getM3Colors(context)
+
+ val container = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(
+ dpToPx(context, 24),
+ dpToPx(context, 24),
+ dpToPx(context, 24),
+ dpToPx(context, 24)
+ )
+ background = createM3Background(context, colors.surface, 28f)
+ }
+
+ val titleView = TextView(context).apply {
+ text = title
+ textSize = 20f
+ setTextColor(colors.onSurface)
+ typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
+ setPadding(0, 0, 0, dpToPx(context, 16))
+ }
+ container.addView(titleView)
+
+ val messageView = TextView(context).apply {
+ text = message
+ textSize = 14f
+ setTextColor(colors.onSurfaceVariant)
+ setPadding(0, 0, 0, dpToPx(context, 24))
+ lineHeight = dpToPx(context, 20)
+ }
+ container.addView(messageView)
+
+ val buttonContainer = LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.END
+ }
+
+ // Cancel button
+ val cancelButton = TextView(context).apply {
+ text = "Cancel"
+ setTextColor(colors.primary)
+ textSize = 14f
+ typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
+ setPadding(
+ dpToPx(context, 16),
+ dpToPx(context, 10),
+ dpToPx(context, 16),
+ dpToPx(context, 10)
+ )
+ }
+
+ // Confirm button
+ val confirmButton = TextView(context).apply {
+ text = "Confirm"
+ setTextColor(colors.primary)
+ textSize = 14f
+ typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
+ setPadding(
+ dpToPx(context, 16),
+ dpToPx(context, 10),
+ dpToPx(context, 16),
+ dpToPx(context, 10)
+ )
+ }
+
+ val dialog = AlertDialog.Builder(context)
+ .setView(container)
+ .create()
+
+ cancelButton.setOnClickListener { dialog.dismiss() }
+ confirmButton.setOnClickListener {
+ dialog.dismiss()
+ action()
+ }
+
+ buttonContainer.addView(cancelButton)
+ buttonContainer.addView(confirmButton)
+ container.addView(buttonContainer)
+
+ dialog.window?.setBackgroundDrawable(
+ createM3Background(context, Color.TRANSPARENT, 28f)
+ )
+
+ dialog.show()
+ }
+
+ private fun isSafeModeEnabled(context: Context): Boolean {
+ return try {
+ val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
+ if (!settingsFile.exists()) return false
+
+ val json = JSONObject(settingsFile.readText())
+ json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false
+ } catch (e: Exception) {
+ Log.e("Error checking safe mode: ${e.message}")
+ false
+ }
+ }
+
+ private fun toggleSafeMode(context: Context) {
+ try {
+ val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
+ val themeFile = File(context.filesDir, "vd_mmkv/VENDETTA_THEMES")
+
+ settingsFile.parentFile?.mkdirs()
+
+ val settings = if (settingsFile.exists()) {
+ JSONObject(settingsFile.readText())
+ } else {
+ JSONObject()
+ }
+
+ val safeMode = settings.optJSONObject("safeMode") ?: JSONObject()
+ val currentState = safeMode.optBoolean("enabled", false)
+ val newState = !currentState
+
+ safeMode.put("enabled", newState)
+
+ if (newState && themeFile.exists()) {
+ val theme = JSONObject(themeFile.readText())
+ val themeId = theme.optString("id")
+ if (themeId.isNotEmpty()) {
+ safeMode.put("currentThemeId", themeId)
+ themeFile.delete()
+ }
+ }
+
+ settings.put("safeMode", safeMode)
+ settingsFile.writeText(settings.toString())
+
+ showM3Toast(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}")
+ reloadApp()
+
+ } catch (e: Exception) {
+ Log.e("Error toggling safe mode: ${e.message}")
+ showError(context, "Failed to toggle safe mode", e.message)
+ }
+ }
+
+ private fun showM3Toast(context: Context, message: String) {
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+
+ private fun refetchBundle(context: Context) {
+ try {
+ val pyoncordDir = getPyoncordDirectory(context)
+ val bundleFile = File(pyoncordDir, "bundle.js")
+ val backupFile = File(pyoncordDir, "bundle.js.backup")
+
+ if (bundleFile.exists()) {
+ backupFile.delete()
+ bundleFile.renameTo(backupFile)
+ Log.e("Bundle moved to backup")
+ }
+
+ reloadApp()
+
+ } catch (e: Exception) {
+ Log.e("Error refetching bundle: ${e.message}")
+ showError(context, "Failed to refetch bundle", e.message)
+ }
+ }
+
+ private fun clearCacheAndReset(context: Context) {
+ scope.launch(Dispatchers.IO) {
+ try {
+ val pyoncordDir = getPyoncordDirectory(context)
+ val bundleFile = File(pyoncordDir, "bundle.js")
+ val configFile = File(pyoncordDir, "loader.json")
+
+ bundleFile.delete()
+
+ if (configFile.exists()) {
+ val config = JSONObject(configFile.readText())
+ val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
+ customLoadUrl.put("enabled", false)
+ customLoadUrl.put("url", "http://localhost:4040/shiggycord.js")
+ config.put("customLoadUrl", customLoadUrl)
+ configFile.writeText(config.toString())
+ }
+
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Cache cleared successfully", Toast.LENGTH_SHORT).show()
+ reloadApp()
+ }
+
+ } catch (e: Exception) {
+ Log.e("Error clearing cache: ${e.message}")
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Failed to clear cache", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ private fun showCustomBundleDialog(context: Context) {
+ val colors = getM3Colors(context)
+ val filesDir = File(context.filesDir, "pyoncord")
+ val configFile = File(filesDir, "loader.json")
+ var currentUrl: String? = null
+ var isEnabled = false
+
+ if (configFile.exists()) {
+ try {
+ val json = JSONObject(configFile.readText())
+ val customLoadUrl = json.optJSONObject("customLoadUrl")
+ if (customLoadUrl != null) {
+ isEnabled = customLoadUrl.optBoolean("enabled", false)
+ currentUrl = customLoadUrl.optString("url", "")
+ }
+ } catch (_: Exception) {
+ }
+ }
+
+ val container = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(
+ dpToPx(context, 24),
+ dpToPx(context, 24),
+ dpToPx(context, 24),
+ dpToPx(context, 24)
+ )
+ background = createM3Background(context, colors.surface, 28f)
+ }
+
+ val titleView = TextView(context).apply {
+ text = "Custom Bundle URL"
+ textSize = 20f
+ setTextColor(colors.onSurface)
+ typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
+ setPadding(0, 0, 0, dpToPx(context, 16))
+ }
+ container.addView(titleView)
+
+ // Toggle switch container
+ val toggleContainer = LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ val params = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ setMargins(0, 0, 0, dpToPx(context, 16))
+ }
+ layoutParams = params
+ }
+
+ val toggleLabel = TextView(context).apply {
+ text = "Enable Custom URL"
+ textSize = 14f
+ setTextColor(colors.onSurface)
+ typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
+ layoutParams = LinearLayout.LayoutParams(
+ 0,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ 1f
+ )
+ }
+
+ val toggleSwitch = createM3Switch(context, colors, isEnabled)
+
+ toggleContainer.addView(toggleLabel)
+ toggleContainer.addView(toggleSwitch)
+ container.addView(toggleContainer)
+
+ val urlInput = EditText(context).apply {
+ hint = "http://localhost:4040/shiggycord.js"
+ setTextColor(colors.onSurface)
+ setHintTextColor(colors.onSurfaceVariant)
+ background = createM3Background(context, colors.surfaceVariant, 12f)
+ inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
+ setText(currentUrl ?: "")
+ textSize = 14f
+ setPadding(
+ dpToPx(context, 16),
+ dpToPx(context, 12),
+ dpToPx(context, 16),
+ dpToPx(context, 12)
+ )
+ isEnabled
+ alpha = if (isEnabled) 1f else 0.5f
+ val params = LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ ).apply {
+ setMargins(0, 0, 0, dpToPx(context, 24))
+ }
+ layoutParams = params
+ }
+ container.addView(urlInput)
+
+ toggleSwitch.setOnClickListener {
+ val currentState = toggleSwitch.tag as Boolean
+ val newState = !currentState
+ toggleSwitch.tag = newState
+ updateSwitchAppearance(context, toggleSwitch, colors, newState)
+
+ urlInput.animate()
+ .alpha(if (newState) 1f else 0.5f)
+ .setDuration(250)
+ .withEndAction {
+ urlInput.isEnabled = newState
+ }
+ .start()
+ }
+
+ val buttonContainer = LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.END
+ }
+
+ val cancelButton = TextView(context).apply {
+ text = "Cancel"
+ setTextColor(colors.primary)
+ textSize = 14f
+ typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
+ setPadding(
+ dpToPx(context, 16),
+ dpToPx(context, 10),
+ dpToPx(context, 16),
+ dpToPx(context, 10)
+ )
+ }
+
+ val saveButton = TextView(context).apply {
+ text = "Save"
+ setTextColor(colors.primary)
+ textSize = 14f
+ typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
+ setPadding(
+ dpToPx(context, 16),
+ dpToPx(context, 10),
+ dpToPx(context, 16),
+ dpToPx(context, 10)
+ )
+ }
+
+ val dialog = AlertDialog.Builder(context)
+ .setView(container)
+ .create()
+
+ cancelButton.setOnClickListener { dialog.dismiss() }
+ saveButton.setOnClickListener {
+ try {
+ val url = urlInput.text?.toString()?.trim() ?: ""
+ val enabled = toggleSwitch.tag as Boolean
+
+ if (enabled && url.isNotEmpty()) {
+ setCustomBundleURL(context, url, true)
+ dialog.dismiss()
+ } else if (!enabled) {
+ setCustomBundleURL(context, url.ifEmpty { "http://localhost:4040/shiggycord.js" }, false)
+ dialog.dismiss()
+ } else {
+ Toast.makeText(context, "Please enter a URL", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(context, "Failed to set custom bundle: ${e.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+
+ buttonContainer.addView(cancelButton)
+ buttonContainer.addView(saveButton)
+ container.addView(buttonContainer)
+
+ dialog.window?.setBackgroundDrawable(
+ createM3Background(context, Color.TRANSPARENT, 28f)
+ )
+
+ dialog.show()
+ }
+
+ private fun createM3Switch(context: Context, colors: M3Colors, isChecked: Boolean): View {
+ val switchView = LinearLayout(context).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.CENTER_VERTICAL
+ tag = isChecked
+ layoutParams = LinearLayout.LayoutParams(
+ dpToPx(context, 52),
+ dpToPx(context, 32)
+ )
+ }
+
+ updateSwitchAppearance(context, switchView, colors, isChecked)
+ return switchView
+ }
+
+ private fun updateSwitchAppearance(context: Context, switchView: View, colors: M3Colors, isChecked: Boolean) {
+ val trackColor = if (isChecked) colors.primary else colors.surfaceVariant
+ val thumbColor = if (isChecked) colors.onPrimary else colors.onSurfaceVariant
+
+ val currentTrackColor = if (switchView.background is GradientDrawable) {
+ (switchView.background as GradientDrawable).color?.defaultColor
+ ?: (if (!isChecked) colors.primary else colors.surfaceVariant)
+ } else {
+ if (!isChecked) colors.primary else colors.surfaceVariant
+ }
+
+ val trackColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentTrackColor, trackColor)
+ trackColorAnimator.duration = 250
+ trackColorAnimator.interpolator = DecelerateInterpolator()
+ trackColorAnimator.addUpdateListener { animator ->
+ val animatedColor = animator.animatedValue as Int
+ val track = GradientDrawable().apply {
+ setColor(animatedColor)
+ setCornerRadius(dpToPx(context, 16).toFloat())
+ }
+ switchView.background = track
+ }
+
+ if (switchView is LinearLayout) {
+ val thumb = if (switchView.childCount > 0) {
+ switchView.getChildAt(0)
+ } else {
+ // Create new thumb if none exists
+ View(context).apply {
+ val size = dpToPx(context, 24)
+ layoutParams = LinearLayout.LayoutParams(size, size).apply {
+ marginStart = dpToPx(context, 4)
+ marginEnd = dpToPx(context, 24)
+ }
+ switchView.addView(this)
+ }
+ }
+
+ val currentParams = thumb.layoutParams as LinearLayout.LayoutParams
+ val targetMarginStart = if (isChecked) dpToPx(context, 24) else dpToPx(context, 4)
+
+ val marginAnimator = ValueAnimator.ofInt(currentParams.marginStart, targetMarginStart)
+ marginAnimator.duration = 250
+ marginAnimator.interpolator = DecelerateInterpolator()
+ marginAnimator.addUpdateListener { animator ->
+ val animatedMargin = animator.animatedValue as Int
+ val params = thumb.layoutParams as LinearLayout.LayoutParams
+ params.marginStart = animatedMargin
+ params.marginEnd = dpToPx(context, 28) - animatedMargin
+ thumb.layoutParams = params
+ }
+
+ val currentThumbColor = if (thumb.background is GradientDrawable) {
+ (thumb.background as GradientDrawable).color?.defaultColor
+ ?: (if (!isChecked) colors.onPrimary else colors.onSurfaceVariant)
+ } else {
+ if (!isChecked) colors.onPrimary else colors.onSurfaceVariant
+ }
+
+ val thumbColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentThumbColor, thumbColor)
+ thumbColorAnimator.duration = 250
+ thumbColorAnimator.interpolator = DecelerateInterpolator()
+ thumbColorAnimator.addUpdateListener { animator ->
+ val animatedColor = animator.animatedValue as Int
+ thumb.background = GradientDrawable().apply {
+ setColor(animatedColor)
+ shape = GradientDrawable.OVAL
+ }
+ }
+
+ val animatorSet = AnimatorSet()
+ animatorSet.playTogether(trackColorAnimator, marginAnimator, thumbColorAnimator)
+ animatorSet.start()
+ }
+ }
+
+ private fun setCustomBundleURL(context: Context, url: String, enabled: Boolean) {
+ try {
+ val pyoncordDir = getPyoncordDirectory(context)
+ val configFile = File(pyoncordDir, "loader.json")
+
+ val config = if (configFile.exists()) {
+ JSONObject(configFile.readText())
+ } else {
+ JSONObject().apply {
+ put("loadReactDevTools", false)
+ }
+ }
+
+ val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
+ customLoadUrl.put("enabled", enabled)
+ customLoadUrl.put("url", url)
+ config.put("customLoadUrl", customLoadUrl)
+
+ configFile.writeText(config.toString())
+
+ if (enabled) {
+ File(pyoncordDir, "bundle.js").delete()
+ Toast.makeText(context, "Custom bundle enabled", Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(context, "Custom bundle disabled", Toast.LENGTH_SHORT).show()
+ }
+
+ reloadApp()
+ } catch (e: Exception) {
+ Log.e("Error setting custom bundle URL: ${e.message}")
+ showError(context, "Failed to save configuration", e.message)
+ }
+ }
+
+ private fun disableCustomBundle(context: Context) {
+ try {
+ val configFile = File(getPyoncordDirectory(context), "loader.json")
+ val config = if (configFile.exists()) {
+ JSONObject(configFile.readText())
+ } else {
+ JSONObject().apply {
+ put("loadReactDevTools", false)
+ }
+ }
+
+ val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
+ customLoadUrl.put("enabled", false)
+ customLoadUrl.put("url", "http://localhost:4040/shiggycord.js")
+ config.put("customLoadUrl", customLoadUrl)
+
+ configFile.writeText(config.toString())
+ Toast.makeText(context, "Custom bundle disabled", Toast.LENGTH_SHORT).show()
+ reloadApp()
+ } catch (e: Exception) {
+ Toast.makeText(context, "Failed to disable custom bundle", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun resetBundle(context: Context) {
+ try {
+ val pyoncordDir = getPyoncordDirectory(context)
+ val bundleFile = File(pyoncordDir, "bundle.js")
+ val backupFile = File(pyoncordDir, "bundle.js.backup")
+ val configFile = File(pyoncordDir, "loader_config.json")
+
+ bundleFile.delete()
+ backupFile.delete()
+
+ if (configFile.exists()) {
+ val config = JSONObject(configFile.readText())
+ config.put("customLoadUrlEnabled", false)
+ configFile.writeText(config.toString())
+ }
+
+ reloadApp()
+ } catch (e: Exception) {
+ Log.e("Error resetting bundle: ${e.message}")
+ showError(context, "Failed to reset bundle", e.message)
+ }
+ }
+
+ private fun getPyoncordDirectory(context: Context): File {
+ val dir = File(context.filesDir, "pyoncord")
+ if (!dir.exists()) {
+ dir.mkdirs()
+ }
+ return dir
+ }
+
+ private fun showError(context: Context, title: String, message: String?) {
+ val colors = getM3Colors(context)
+
+ val container = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(
+ dpToPx(context, 24),
+ dpToPx(context, 24),
+ dpToPx(context, 24),
+ dpToPx(context, 24)
+ )
+ background = createM3Background(context, colors.surface, 28f)
+ }
+
+ val titleView = TextView(context).apply {
+ text = title
+ textSize = 20f
+ setTextColor(colors.error)
+ typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
+ setPadding(0, 0, 0, dpToPx(context, 16))
+ }
+ container.addView(titleView)
+
+ val messageView = TextView(context).apply {
+ text = message ?: "An unknown error occurred"
+ textSize = 14f
+ setTextColor(colors.onSurfaceVariant)
+ setPadding(0, 0, 0, dpToPx(context, 24))
+ lineHeight = dpToPx(context, 20)
+ }
+ container.addView(messageView)
+
+ val okButton = TextView(context).apply {
+ text = "OK"
+ setTextColor(colors.primary)
+ textSize = 14f
+ typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
+ gravity = Gravity.END
+ setPadding(
+ dpToPx(context, 16),
+ dpToPx(context, 10),
+ dpToPx(context, 16),
+ dpToPx(context, 10)
+ )
+ }
+
+ val dialog = AlertDialog.Builder(context)
+ .setView(container)
+ .create()
+
+ okButton.setOnClickListener { dialog.dismiss() }
+ container.addView(okButton)
+
+ dialog.window?.setBackgroundDrawable(
+ createM3Background(context, Color.TRANSPARENT, 28f)
+ )
+
+ dialog.show()
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/PayloadGlobalModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/PayloadGlobalModule.kt
new file mode 100644
index 0000000..03d0601
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/PayloadGlobalModule.kt
@@ -0,0 +1,80 @@
+package GoonXposed.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 GoonXposed.xposed.BuildConfig
+import GoonXposed.xposed.Constants
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Companion.JSON
+import GoonXposed.xposed.modules.HookScriptLoaderModule.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 scriptLoader = classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance\$loadJSBundle$1")
+ ?: 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(
+ scriptLoader!!.method(
+ "loadScriptFromFile",
+ String::class.java,
+ String::class.java,
+ Boolean::class.javaPrimitiveType
+ ), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2])
+ )
+
+ delete()
+ }
+ }
+ }
+
+ val hook = MethodHookBuilder().run {
+ before {
+ setGlobalVariable(param, GLOBAL_NAME, getPayloadString())
+ }
+
+ build()
+ }
+
+ listOf(catalystInstance, scriptLoader).forEach { if (it != null) hook(it, hook) }
+ }
+
+ private fun hook(instance: Class<*>, hook: XC_MethodHook) {
+ instance.method(
+ "loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType,
+ ).hook(hook)
+
+ instance.method(
+ "loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType
+ ).hook(hook)
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/PluginsModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/PluginsModule.kt
new file mode 100644
index 0000000..8aae94b
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/PluginsModule.kt
@@ -0,0 +1,124 @@
+package GoonXposed.xposed.modules
+
+import android.content.Context
+import android.util.AtomicFile
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Log
+import GoonXposed.xposed.modules.bridge.BridgeModule
+import java.io.*
+
+/**
+ * Manages the states of plugins, allowing reading and writing of plugin states to a file.
+ *
+ * ## Methods
+ *
+ * - `Shiggy.plugins.states.read(): { flags: { [pluginId: string]: number } }`
+ * - Reads the current plugin states from the file and returns them as a map.
+ *
+ * - `Shiggy.plugins.states.write(flags: { [pluginId: string]: number }): void`
+ * - Writes the provided plugin states to the file.
+ */
+class PluginsModule : Module() {
+ private companion object {
+ const val DATA_DIR = "Shiggy/plugins"
+ const val STATES_FILE = "states"
+ }
+
+ private lateinit var states: PluginStates
+
+ override fun onContext(context: Context) = with(context) {
+ val dataDir = File(filesDir, DATA_DIR)
+
+ val statesFile = File(
+ dataDir, STATES_FILE
+ ).apply { asFile() }
+
+ BridgeModule.registerMethod("Shiggy.plugins.states.read") {
+ if (::states.isInitialized) states.toMap() else
+ PluginStates.loadFromFileOrNull(statesFile)?.let {
+ states = it
+ it.toMap()
+ }
+ }
+
+ BridgeModule.registerMethod("Shiggy.plugins.states.write") {
+ val (flags) = it
+ @Suppress("UNCHECKED_CAST")
+ states = PluginStates(
+ flags as Map
+ ).apply { saveToFile(statesFile) }
+ Log.i("Plugin states saved: ${statesFile.absolutePath}")
+ }
+ }
+}
+
+private class UnsupportedPluginStatesVersionException(version: Int) :
+ Throwable("Unsupported plugin states version: $version")
+
+data class PluginStates(
+ val flags: Map
+) {
+ fun saveToFile(file: File) {
+ val atomic = AtomicFile(file)
+ var fos: FileOutputStream? = null
+ try {
+ fos = atomic.startWrite()
+ val out = DataOutputStream(BufferedOutputStream(fos))
+
+ out.writeInt(CURRENT_VERSION)
+
+ // Write flags
+ out.writeInt(flags.size)
+ for ((id, flags) in flags) {
+ out.writeUTF(id)
+ out.writeInt(flags.toInt())
+ }
+
+ out.flush()
+ atomic.finishWrite(fos)
+ } catch (t: Throwable) {
+ if (fos != null) atomic.failWrite(fos)
+ throw t
+ }
+ }
+
+ fun toMap(): Map = mapOf("flags" to flags)
+
+ companion object {
+ const val CURRENT_VERSION = 1
+
+ fun loadFromFileOrNull(file: File): PluginStates? {
+ if (!file.exists() || file.length() <= 0L) return null
+ val atomic = AtomicFile(file)
+
+ try {
+ DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
+ when (val version = input.readInt()) {
+ 1 -> return loadV1FromFileOrNull(input)
+ else -> throw UnsupportedPluginStatesVersionException(version)
+ }
+ }
+ } catch (e: UnsupportedPluginStatesVersionException) {
+ Log.i(e.message!!)
+ } catch (e: EOFException) {
+ Log.e("Plugin states corrupt: ${e.message}")
+ } catch (e: IOException) {
+ Log.e("Failed to read plugin states: ${e.message}")
+ }
+
+ runCatching { file.delete() }
+ return null
+ }
+
+ private fun loadV1FromFileOrNull(input: DataInputStream): PluginStates {
+ val flagsSize = input.readInt()
+ val flags = mutableMapOf()
+
+ repeat(flagsSize) {
+ flags[input.readUTF()] = input.readInt().toDouble()
+ }
+
+ return PluginStates(flags)
+ }
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/UpdaterModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/UpdaterModule.kt
new file mode 100644
index 0000000..2ee6296
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/UpdaterModule.kt
@@ -0,0 +1,155 @@
+package GoonXposed.xposed.modules
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Context
+import android.util.AtomicFile
+import android.widget.Toast
+import androidx.core.util.writeBytes
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Constants
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils
+import GoonXposed.xposed.Utils.Companion.JSON
+import GoonXposed.xposed.Utils.Companion.reloadApp
+import GoonXposed.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
+import java.lang.ref.WeakReference
+
+@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.
+ */
+object UpdaterModule : Module() {
+ private lateinit var config: LoaderConfig
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var lastActivity: WeakReference? = null
+
+ private lateinit var cacheDir: File
+ private lateinit var bundle: File
+ private lateinit var etag: File
+
+ 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/OTKUSteyler/GoonCord/releases/download/latest/gooncord.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())
+ }
+
+ 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("GoonCord 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)
+ showErrorDialog(e)
+ }
+ }
+
+ override fun onActivity(activity: Activity) {
+ lastActivity = WeakReference(activity)
+ }
+
+ fun showErrorDialog(e: Throwable) {
+ val activity = lastActivity?.get() ?: return
+
+ activity.runOnUiThread {
+ }
+ }
+
+ fun resetLoaderConfig(context: Context) {
+ val filesDir = File(context.dataDir, Constants.FILES_DIR)
+ val config = File(filesDir, CONFIG_FILE)
+ if (config.exists()) config.delete()
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/FontsModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/FontsModule.kt
new file mode 100644
index 0000000..2a121e7
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/FontsModule.kt
@@ -0,0 +1,228 @@
+package GoonXposed.xposed.modules.appearance
+
+import android.content.res.AssetManager
+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 GoonXposed.xposed.Constants
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Companion.JSON
+import GoonXposed.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.JsonObjectBuilder
+import kotlinx.serialization.json.put
+import java.io.File
+import java.io.IOException
+
+@Serializable
+data class FontDefinition(
+ val name: String? = null,
+ val description: String? = null,
+ val spec: Int? = null,
+ val main: Map,
+)
+
+object FontsModule : Module() {
+ private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic")
+ private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
+ private const val FONTS_ASSET_PATH = "fonts/"
+
+ private lateinit var fontsDir: File
+ private lateinit var fontsDownloadsDir: File
+ private var fontsAbsPath: String? = null
+
+ override fun buildPayload(builder: JsonObjectBuilder) {
+ builder.apply {
+ put("fontPatch", 2)
+ }
+ }
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
+ XposedHelpers.findAndHookMethod(
+ "com.facebook.react.views.text.ReactFontManager\$Companion",
+ classLoader,
+ "createAssetTypeface",
+ String::class.java,
+ Int::class.java,
+ "android.content.res.AssetManager",
+ object : XC_MethodReplacement() {
+ override fun replaceHookedMethod(param: MethodHookParam): Typeface? {
+ val fontFamilyName: String = param.args[0].toString()
+ val style: Int = param.args[1] as Int
+ 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
+ }
+
+ fontsDownloadsDir = File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { asDir() }
+ fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() }
+ 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) }}").apply { asFile() }
+ 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()
+ }
+ }
+
+ private fun createAssetTypefaceWithFallbacks(
+ fontFamilyNames: Array, style: Int, assetManager: AssetManager
+ ): Typeface? {
+ val fontFamilies: MutableList = ArrayList()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Iterate over the list of fontFamilyNames, constructing new FontFamily objects
+ // for use in the CustomFallbackBuilder below.
+ for (fontFamilyName in fontFamilyNames) {
+ try {
+ for (fileExtension in FILE_EXTENSIONS) {
+ val (customName, refName) = fontFamilyName.split(":")
+ val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { asFile() }
+ val font = Font.Builder(file).build()
+ val family = FontFamily.Builder(font).build()
+ fontFamilies.add(family)
+ }
+ } 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()
+ try {
+ 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)
+ } catch (_: RuntimeException) {
+ // If the typeface asset does not exist, try another extension.
+ continue
+ } catch (_: IOException) {
+ // If the font asset does not exist, try another extension.
+ continue
+ }
+ }
+ }
+ }
+
+ // If there's some problem constructing fonts, fall back to the default behavior.
+ 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])
+ }
+ return fallbackBuilder.build()
+ }
+ return null
+ }
+
+ private fun createAssetTypeface(
+ 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
+ val fontFamilyNames = fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ for (i in fontFamilyNames.indices) {
+ fontFamilyNames[i] = fontFamilyNames[i].trim()
+ }
+
+ // If there are multiple font family names:
+ // For newer versions of Android, construct a Typeface with fallbacks
+ // For older versions of Android, ignore all the fallbacks and just use the first font family
+ if (fontFamilyNames.size > 1) {
+ fontFamilyName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return createAssetTypefaceWithFallbacks(fontFamilyNames, style, assetManager)
+ } else {
+ fontFamilyNames[0]
+ }
+ }
+
+ val extension = EXTENSIONS[style]
+
+ try {
+ for (fileExtension in FILE_EXTENSIONS) {
+ val (customName, refName) = fontFamilyName.split(":")
+ val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { asFile() }
+ if (!file.exists()) throw Exception()
+ return Typeface.createFromFile(file.absolutePath)
+ }
+ } 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()
+
+ return try {
+ if (fileName[0] == '/') Typeface.createFromFile(fileName)
+ else Typeface.createFromAsset(assetManager, fileName)
+ } catch (_: RuntimeException) {
+ // If the typeface asset does not exist, try another extension.
+ continue
+ }
+ }
+ }
+
+ return Typeface.create(fontFamilyName, style)
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/SysColorsModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/SysColorsModule.kt
new file mode 100644
index 0000000..3807b09
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/SysColorsModule.kt
@@ -0,0 +1,43 @@
+package GoonXposed.xposed.modules.appearance
+
+import android.R.color
+import android.app.AndroidAppHelper
+import android.content.Context
+import android.os.Build
+import androidx.core.content.ContextCompat
+import GoonXposed.xposed.Module
+import kotlinx.serialization.json.*
+import java.lang.ref.WeakReference
+
+object SysColorsModule : Module() {
+ private lateinit var context: WeakReference
+ private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+
+ override fun buildPayload(builder: JsonObjectBuilder) {
+ context = WeakReference(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)
+
+ builder.apply {
+ put("isSysColorsSupported", isSupported())
+ if (isSupported()) putJsonObject("sysColors") {
+ for (accent in accents) putJsonArray(accent) {
+ for (shade in shades) {
+ val colorName = "system_" + accent + "_" + shade
+
+ val colorResourceId = runCatching {
+ color::class.java.getField(colorName).getInt(null)
+ }.getOrElse { 0 }
+
+ add(convertToColor(colorResourceId))
+ }
+ }
+ }
+ }
+ }
+
+ private fun convertToColor(id: Int): String {
+ val clr = if (isSupported()) ContextCompat.getColor(context.get()!!, id) else 0
+ return String.format("#%06X", 0xFFFFFF and clr)
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/ThemesModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/ThemesModule.kt
new file mode 100644
index 0000000..2e08714
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/ThemesModule.kt
@@ -0,0 +1,159 @@
+package GoonXposed.xposed.modules.appearance
+
+import android.content.Context
+import android.content.res.Resources
+import androidx.core.graphics.toColorInt
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Constants
+import GoonXposed.xposed.Module
+import GoonXposed.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
+)
+
+@Serializable
+data class ThemeData(
+ val name: String,
+ val description: String? = null,
+ val authors: List? = null,
+ val spec: Int,
+ val semanticColors: Map>? = null,
+ val rawColors: Map? = null
+)
+
+@Serializable
+data class Theme(
+ val id: String, val selected: Boolean, val data: ThemeData
+)
+
+object ThemesModule : Module() {
+ private lateinit var param: XC_LoadPackage.LoadPackageParam
+
+ private var theme: Theme? = null
+ private val rawColorMap = mutableMapOf()
+
+ private 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)
+ }
+ }
+
+ private fun String.fromScreamingSnakeToCamelCase() =
+ this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } }
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
+ param = packageParam
+ theme = getTheme()
+ hookTheme()
+ }
+
+ private fun File.isValidThemeFile(): Boolean {
+ if (!this.exists()) return false
+
+ val text = this.readText()
+ return text.isNotBlank() && text != "{}" && text != "null"
+ }
+
+ private fun getTheme(): Theme? {
+ val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { asFile() }
+ if (!themeFile.isValidThemeFile()) return null
+
+ return try {
+ val themeText = themeFile.readText()
+ JSON.decodeFromString(themeText)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ fun hookTheme() {
+ val themeManager = param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt")
+ val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme")
+ val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme")
+
+ val theme = this.theme ?: return
+
+ // Apply rawColors
+ theme.data.rawColors?.forEach { (key, value) ->
+ rawColorMap[key.lowercase()] = hexStringToColorInt(value)
+ }
+
+ // Apply semanticColors
+ theme.data.semanticColors?.forEach { (key, value) ->
+ // TEXT_NORMAL -> getTextNormal
+ val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
+ value.forEachIndexed { index, v ->
+ when (index) {
+ 0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v))
+ 1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v))
+ }
+ }
+ }
+
+ // If there's any rawColors value, hook the color getter
+ if (!theme.data.rawColors.isNullOrEmpty()) {
+ val getColorCompat = themeManager.getDeclaredMethod(
+ "getColorCompat",
+ Resources::class.java,
+ Int::class.javaPrimitiveType,
+ Resources.Theme::class.java,
+ )
+
+ val getColorCompatLegacy = themeManager.getDeclaredMethod(
+ "getColorCompat", Context::class.java, Int::class.javaPrimitiveType
+ )
+
+ 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(args[1] as Int)
+
+ if (rawColorMap[name] != null) result = rawColorMap[name]
+ }
+
+ build()
+ }
+
+ 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) {
+ // Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy
+ val alpha = hexString.substring(7, 9)
+ val rrggbb = hexString.substring(1, 7)
+ "#$alpha$rrggbb".toColorInt()
+ } else hexString.toColorInt()
+ }
+
+ private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
+ try {
+ themeClass.getDeclaredMethod(methodName).let { method ->
+ method.hook {
+ before {
+ result = themeValue
+ }
+ }
+ }
+ } catch (_: NoSuchMethodException) {
+ }
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt
new file mode 100644
index 0000000..178e0d9
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt
@@ -0,0 +1,94 @@
+package GoonXposed.xposed.modules.bridge
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Build
+import android.widget.Toast
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils
+import java.io.File
+
+object AdditionalBridgeMethodsModule : Module() {
+ override fun onContext(context: Context) = with(context) {
+ BridgeModule.registerMethod("Shiggy.fs.getConstants") {
+ mapOf(
+ "data" to dataDir.absolutePath,
+ "files" to filesDir.absolutePath,
+ "cache" to cacheDir.absolutePath,
+ )
+ }
+
+ BridgeModule.registerMethod("Shiggy.fs.delete") {
+ val (path) = it
+ File(path as String).run {
+ if (this.isDirectory) this.deleteRecursively()
+ else this.delete()
+ }
+ }
+
+ BridgeModule.registerMethod("Shiggy.fs.exists") {
+ val (path) = it
+ File(path as String).exists()
+ }
+
+ BridgeModule.registerMethod("Shiggy.fs.read") { it ->
+ val (path) = it
+ val file = File(path as String).apply { openFileGuarded() }
+
+ file.bufferedReader().use { it.readText() }
+ }
+
+ BridgeModule.registerMethod("Shiggy.fs.write") {
+ val (path, contents) = it
+ val file = File(path as String).apply { openFileGuarded() }
+
+ file.writeText(contents as String)
+ }
+ }
+
+ override fun onActivity(activity: Activity) = with(activity) {
+ BridgeModule.registerMethod("Shiggy.alertError") {
+ val (error, version) = it
+ val app = getAppInfo()
+ val errorString = "$error"
+
+ val clipboard = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText("Stack Trace", errorString)
+
+ AlertDialog.Builder(this)
+ .setTitle("GoonCord Error")
+ .setMessage(
+ """
+ GoonCord: $version
+ ${app.name}: ${app.version} (${app.versionCode})
+ Device: ${Build.MANUFACTURER} ${Build.MODEL}
+
+
+ """.trimIndent() + errorString
+ )
+ .setPositiveButton("OK") { dialog, _ ->
+ dialog.dismiss()
+ }
+ .setNeutralButton("Copy") { dialog, _ ->
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(applicationContext, "Copied stack trace", Toast.LENGTH_SHORT).show()
+ dialog.dismiss()
+ }
+ .show()
+
+ null
+ }
+
+ BridgeModule.registerMethod("Shiggy.showRecoveryAlert") {
+ Utils.showRecoveryAlert(this)
+ }
+ }
+
+ 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")
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/bridge/BridgeModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/bridge/BridgeModule.kt
new file mode 100644
index 0000000..17d6474
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/bridge/BridgeModule.kt
@@ -0,0 +1,157 @@
+package GoonXposed.xposed.modules.bridge
+
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.BuildConfig
+import GoonXposed.xposed.Constants
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Log
+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
+ * {
+ * Shiggy: {
+ * 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"
+ * }
+ * ```
+ */
+object BridgeModule : Module() {
+ private lateinit var readableMapGetString: Method
+ private lateinit var readableMapToHashMap: Method
+ private lateinit var argumentsMakeNative: Method
+
+ private const val CALL_DATA_KEY = "Shiggy"
+ private const val METHOD_NAME_KEY = "method"
+ private const val METHOD_ARGS_KEY = "args"
+
+ private val methods: MutableMap = mutableMapOf()
+
+ /**
+ * 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
+ }
+ }
+ }
+
+ // Clear methods every package load. Plugins should re-register on each load.
+ methods.clear()
+ registerDefaultMethods()
+
+ return@with
+ }
+
+ private fun registerDefaultMethods() {
+ methods["Shiggy.info"] = {
+ mapOf(
+ "name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE
+ )
+ }
+
+ methods["Shiggy.test"] = {
+ 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,
+ )
+ }
+ }
+
+ 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.stackTraceToString())
+ }
+
+ 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/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt
new file mode 100644
index 0000000..c15d72b
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt
@@ -0,0 +1,50 @@
+package GoonXposed.xposed.modules.no_track
+
+import android.content.Context
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Log
+
+/**
+ * Hooks Discord's crash reporting to disable Sentry initialization.
+ * While Discord doesn't have Sentry auto-initialization on by default, we still hook the content provider just to be safe.
+ */
+object BlockCrashReportingModule : Module() {
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
+ val crashReportingClass = classLoader.safeLoadClass("com.discord.crash_reporting.CrashReporting")
+ crashReportingClass?.apply {
+
+ // This only exists on 30720x and above
+ runCatching {
+ hookMethod("isDisabled") {
+ before {
+ Log.i("Forced CrashReporting.isDisabled() to true")
+ result = true
+ }
+ }
+ }.onFailure {
+ // In older versions, this hook works fine.
+ // Hooking this on 30720x will result in a crash after a few seconds,
+ // since Discord asserts initialization when setting a Sentry tag.
+ hookMethod(
+ "init", Context::class.java, String::class.java
+ ) {
+ before {
+ Log.i("Blocked CrashReporting initialization")
+ result = null
+ }
+ }
+ }
+ }
+
+ val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider")
+ sentryInitProviderClass?.hookMethod("onCreate") {
+ before {
+ Log.i("Blocked SentryInitProvider initialization")
+ result = true
+ }
+ }
+
+ return@with
+ }
+}
diff --git a/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt
new file mode 100644
index 0000000..e47548d
--- /dev/null
+++ b/app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt
@@ -0,0 +1,25 @@
+package GoonXposed.xposed.modules.no_track
+
+import android.content.Context
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Log
+
+/**
+ * Hooks Discord's deep links tracking to disable AppsFlyer initialization.
+ */
+object BlockDeepLinksTrackingModule : Module() {
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
+ val deepLinksClass = classLoader.safeLoadClass("com.discord.deep_link.DeepLinks")
+ deepLinksClass?.hookMethod(
+ "init", Context::class.java
+ ) {
+ before {
+ Log.i("Blocked DeepLinks tracking initialization")
+ result = null
+ }
+ }
+
+ return@with
+ }
+}
diff --git a/app/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt b/app/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt
new file mode 100644
index 0000000..79a1322
--- /dev/null
+++ b/app/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt
@@ -0,0 +1,32 @@
+package GoonXposed.xposed
+
+import android.app.AndroidAppHelper
+import android.content.Intent
+import kotlin.system.exitProcess
+import kotlinx.serialization.json.Json
+
+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 {
+ 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)
+ 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)
+ }
+}
diff --git a/app/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt
new file mode 100644
index 0000000..c6f39fe
--- /dev/null
+++ b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt
@@ -0,0 +1,23 @@
+package GoonXposed.xposed.modules
+
+import android.content.res.Resources
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE
+import GoonXposed.xposed.Module
+
+/**
+ * Hooks [Resources.getIdentifier] to fix resource package name mismatch.
+ */
+object 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
+ }
+ }
+ }
+ }
+}
diff --git a/app/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt
new file mode 100644
index 0000000..a1dbf30
--- /dev/null
+++ b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt
@@ -0,0 +1,121 @@
+package GoonXposed.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 GoonXposed.xposed.Constants
+import GoonXposed.xposed.HookStateHolder
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Log
+import GoonXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.runBlocking
+import java.io.File
+import java.lang.reflect.Method
+
+/**
+ * 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://Shiggy.bundle` from the module's assets.
+ */
+object HookScriptLoaderModule : Module() {
+ private lateinit var preloadsDir: File
+ private lateinit var mainScript: File
+
+ private lateinit var modulePath: String
+ private lateinit var resources: XModuleResources
+
+ 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 { asDir() }
+ mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() }
+
+ listOf(
+ "com.facebook.react.runtime.ReactInstance\$loadJSBundle$1",
+ "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<*>) = runCatching {
+ 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: ${args[1]} (sync: ${args[2]})")
+ runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
+ }
+ }
+
+ loadScriptFromFile.hook {
+ before {
+ Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
+ runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
+ }
+ }
+ }.onFailure {
+ Log.e("Failed to hook script loading methods in ${instance.name}:", it)
+ }
+
+ private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) {
+ Log.i("Running custom scripts...")
+
+ runBlocking {
+ val ready = async { HookStateHolder.readyDeferred.join() }
+ val download = async { UpdaterModule.downloadScript().join() }
+
+ awaitAll(ready, download)
+ }
+
+ 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://Shiggy.bundle", loadSynchronously)
+ )
+ }
+ } catch (e: Throwable) {
+ Log.e("Unable to run scripts:", e)
+ }
+ }
+}
diff --git a/app/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt
new file mode 100644
index 0000000..41042fc
--- /dev/null
+++ b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt
@@ -0,0 +1,601 @@
+package GoonXposed.xposed.modules
+
+import android.app.AlertDialog
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.widget.EditText
+import android.widget.Toast
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.BuildConfig
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils
+import GoonXposed.xposed.Utils.Log
+import de.robv.android.xposed.XposedHelpers
+import kotlinx.coroutines.*
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+import GoonXposed.xposed.Utils.Companion.reloadApp
+
+object LogBoxModule : Module() {
+ lateinit var packageParam: XC_LoadPackage.LoadPackageParam
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
+ this@LogBoxModule.packageParam = packageParam
+
+ val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
+ val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
+
+ getUseDeveloperSupportMethod.hook {
+ before {
+ result = true
+ }
+ }
+
+ listOf(
+ "com.facebook.react.devsupport.BridgeDevSupportManager",
+ "com.facebook.react.devsupport.BridgelessDevSupportManager"
+ ).mapNotNull { packageParam.classLoader.safeLoadClass(it) }.forEach {
+ hookDevSupportManager(it)
+ }
+
+ return@with
+ }
+
+ private fun hookDevSupportManager(clazz: Class<*>) {
+ val showDevOptionsDialogMethod = clazz.methods.first { it.name == "showDevOptionsDialog" }
+
+ showDevOptionsDialogMethod.hook {
+ before {
+ try {
+ val context = getContextFromDevSupport(clazz, thisObject)
+ if (context != null) {
+ showRecoveryMenu(context)
+ }
+ result = null
+ } catch (e: Exception) {
+ Log.e("Failed to show recovery menu: ${e.message}")
+ }
+ }
+ }
+ }
+
+ private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? {
+ return try {
+ val mReactInstanceDevHelperField = XposedHelpers.findField(clazz, "mReactInstanceDevHelper")
+ val helper = mReactInstanceDevHelperField.get(instance)
+ val getCurrentActivityMethod = helper?.javaClass?.methods?.first { it.name == "getCurrentActivity" }
+ getCurrentActivityMethod?.invoke(helper) as? Context
+ } catch (e: Exception) {
+ Log.e("Failed to get context: ${e.message}")
+ null
+ }
+ }
+
+ private fun reloadReactNative(context: Context) {
+ try {
+ val reactInstanceManagerClass =
+ packageParam.classLoader.loadClass("com.facebook.react.ReactInstanceManager")
+ val reactApplicationClass = packageParam.classLoader.loadClass("com.facebook.react.ReactApplication")
+
+ val app = context.applicationContext
+
+ if (reactApplicationClass.isInstance(app)) {
+ val getReactNativeHostMethod = reactApplicationClass.methods.first { it.name == "getReactNativeHost" }
+ val reactNativeHost = getReactNativeHostMethod.invoke(app)
+
+ val getReactInstanceManagerMethod =
+ reactNativeHost.javaClass.methods.first { it.name == "getReactInstanceManager" }
+ val reactInstanceManager = getReactInstanceManagerMethod.invoke(reactNativeHost)
+
+ if (reactInstanceManager != null) {
+ val reloadMethod =
+ reactInstanceManagerClass.methods.first { it.name == "recreateReactContextInBackground" }
+ reloadMethod.invoke(reactInstanceManager)
+ } else {
+ showError(context, "Error", "Could not get React Instance Manager")
+ }
+ } else {
+ showError(context, "Error", "Application is not a React Application")
+ }
+ } catch (e: Exception) {
+ Log.e("Failed to Reload App: ${e.message}")
+ showError(context, "Failed to reload", "Error: ${e.message}")
+ }
+ }
+
+ private fun showRecoveryMenu(context: Context) {
+ val options = arrayOf(
+ if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode",
+ "Refetch Bundle",
+ "Load Custom Bundle",
+ "Reload App",
+ "Clear Cache & Reset"
+ )
+
+ val adapter =
+ object : android.widget.ArrayAdapter(context, android.R.layout.select_dialog_item, options) {
+ override fun getView(
+ position: Int,
+ convertView: android.view.View?,
+ parent: android.view.ViewGroup
+ ): android.view.View {
+ val view = super.getView(position, convertView, parent)
+ (view as? android.widget.TextView)?.apply {
+ setTextColor(0xFFFFFFFF.toInt())
+ textSize = 14f
+ setPadding(
+ (16 * context.resources.displayMetrics.density).toInt(),
+ (12 * context.resources.displayMetrics.density).toInt(),
+ (16 * context.resources.displayMetrics.density).toInt(),
+ (12 * context.resources.displayMetrics.density).toInt()
+ )
+ setTypeface(typeface, android.graphics.Typeface.NORMAL)
+ }
+ view.setBackgroundColor(0xFF2D2D2D.toInt())
+ return view
+ }
+ }
+
+ val dialog = AlertDialog.Builder(context)
+ .setTitle("GoonXposed Recovery Menu")
+ .setAdapter(adapter) { _, which ->
+ handleMenuSelection(context, which)
+ }
+ .setNegativeButton("Close", null)
+ .create()
+
+ dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply {
+ setTextColor(0xFFBB86FC.toInt())
+ setBackgroundColor(0xFF1E1E1E.toInt())
+ textSize = 12f
+ setPadding(
+ (20 * context.resources.displayMetrics.density).toInt(),
+ (8 * context.resources.displayMetrics.density).toInt(),
+ (20 * context.resources.displayMetrics.density).toInt(),
+ (8 * context.resources.displayMetrics.density).toInt()
+ )
+ }
+
+ dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt()))
+
+ val titleId = context.resources.getIdentifier("alertTitle", "id", "android")
+ val titleView = dialog.findViewById(titleId)
+ titleView?.apply {
+ setTextColor(0xFFBB86FC.toInt())
+ textSize = 16f
+ setTypeface(typeface, android.graphics.Typeface.BOLD)
+ }
+
+ dialog.listView?.setBackgroundColor(0xFF1E1E1E.toInt())
+ dialog.listView?.divider = android.graphics.drawable.ColorDrawable(0xFF444444.toInt())
+ dialog.listView?.dividerHeight = 1
+ }
+ dialog.show()
+ }
+
+ private fun handleMenuSelection(context: Context, index: Int) {
+ when (index) {
+ 0 -> toggleSafeMode(context)
+ 1 -> showConfirmAction(
+ context, "Refetch Bundle",
+ "This will download the latest bundle from the official server."
+ ) { refetchBundle(context) }
+
+ 2 -> showCustomBundleDialog(context)
+ 3 -> reloadApp()
+ 4 -> showConfirmAction(
+ context, "Clear Cache & Reset",
+ "This will clear all cached bundles and reset to default settings."
+ ) { clearCacheAndReset(context) }
+ }
+ }
+
+ private fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) {
+ val dialog = AlertDialog.Builder(context)
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton("Confirm") { _, _ -> action() }
+ .setNegativeButton("Cancel", null)
+ .create()
+
+ dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply {
+ setTextColor(0xFFFFFFFF.toInt())
+ setBackgroundColor(0xFF1E1E1E.toInt())
+ textSize = 12f
+ }
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply {
+ setTextColor(0xFFBB86FC.toInt())
+ setBackgroundColor(0xFF1E1E1E.toInt())
+ textSize = 12f
+ setTypeface(typeface, android.graphics.Typeface.BOLD)
+ }
+
+ dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt()))
+
+ val titleId = context.resources.getIdentifier("alertTitle", "id", "android")
+ val titleView = dialog.findViewById(titleId)
+ titleView?.apply {
+ setTextColor(0xFFBB86FC.toInt())
+ textSize = 16f
+ }
+
+ val messageId = context.resources.getIdentifier("message", "id", "android")
+ val messageView = dialog.findViewById(messageId)
+ messageView?.apply {
+ setTextColor(0xFFFFFFFF.toInt())
+ textSize = 14f
+ setPadding(
+ (5 * context.resources.displayMetrics.density).toInt(),
+ paddingTop,
+ (5 * context.resources.displayMetrics.density).toInt(),
+ paddingBottom
+ )
+ }
+ }
+ dialog.show()
+ }
+
+ private fun showCustomBundleDialog(context: Context) {
+ val filesDir = File(context.filesDir, "pyoncord")
+ val configFile = File(filesDir, "loader.json")
+ var currentUrl: String? = null
+
+ if (configFile.exists()) {
+ try {
+ val json = JSONObject(configFile.readText())
+ val customLoadUrl = json.optJSONObject("customLoadUrl")
+ if (customLoadUrl != null && customLoadUrl.optBoolean("enabled", false)) {
+ currentUrl = customLoadUrl.optString("url", "")
+ }
+ } catch (_: Exception) {
+ }
+ }
+
+ val urlInput = EditText(context).apply {
+ hint = "http://localhost:4040/shiggycord.js"
+ setTextColor(0xFFFFFFFF.toInt())
+ setBackgroundColor(0xFF2D2D2D.toInt())
+ setHintTextColor(0xFF888888.toInt())
+ inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
+ setText(currentUrl ?: "")
+ gravity = android.view.Gravity.START
+ textAlignment = android.view.View.TEXT_ALIGNMENT_VIEW_START
+ textSize = 14f
+ setPadding(
+ (12 * context.resources.displayMetrics.density).toInt(),
+ (10 * context.resources.displayMetrics.density).toInt(),
+ (12 * context.resources.displayMetrics.density).toInt(),
+ (10 * context.resources.displayMetrics.density).toInt()
+ )
+ }
+
+ val container = android.widget.FrameLayout(context)
+ val params = android.widget.FrameLayout.LayoutParams(
+ (context.resources.displayMetrics.widthPixels * 0.85).toInt(),
+ android.widget.FrameLayout.LayoutParams.WRAP_CONTENT
+ )
+ params.leftMargin = (15 * context.resources.displayMetrics.density).toInt()
+ params.rightMargin = (15 * context.resources.displayMetrics.density).toInt()
+ urlInput.layoutParams = params
+ container.addView(urlInput)
+
+ val dialog = AlertDialog.Builder(context)
+ .setTitle("Custom Bundle URL")
+ .setView(container)
+ .setPositiveButton("Save", null)
+ .setNegativeButton("Cancel", null)
+ .create()
+
+ dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply {
+ setTextColor(0xFFBB86FC.toInt())
+ setBackgroundColor(0xFF1E1E1E.toInt())
+ textSize = 12f
+ }
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply {
+ setTextColor(0xFFBB86FC.toInt())
+ setBackgroundColor(0xFF1E1E1E.toInt())
+ textSize = 12f
+ setTypeface(typeface, android.graphics.Typeface.BOLD)
+ }
+
+ dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt()))
+
+ val titleId = context.resources.getIdentifier("alertTitle", "id", "android")
+ val titleView = dialog.findViewById(titleId)
+ titleView?.apply {
+ setTextColor(0xFFBB86FC.toInt())
+ textSize = 16f
+ }
+ }
+
+ dialog.show()
+
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
+ try {
+ val url = urlInput.text?.toString()?.trim() ?: ""
+ if (url.isNotEmpty()) {
+ setCustomBundleURL(context, url)
+ dialog.dismiss()
+ } else {
+ disableCustomBundle(context)
+ dialog.dismiss()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(context, "Failed to set custom bundle: ${e.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+
+ private fun showError(context: Context, title: String, message: String?) {
+ val dialog = AlertDialog.Builder(context)
+ .setTitle(title)
+ .setMessage(message ?: "An unknown error occurred")
+ .setPositiveButton("Close", null)
+ .create()
+
+ dialog.setOnShowListener {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply {
+ setTextColor(0xFFBB86FC.toInt())
+ setBackgroundColor(0xFF1E1E1E.toInt())
+ textSize = 12f
+ }
+
+ dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt()))
+
+ val titleId = context.resources.getIdentifier("alertTitle", "id", "android")
+ val titleView = dialog.findViewById(titleId)
+ titleView?.setTextColor(0xFFBB86FC.toInt())
+
+ val messageId = context.resources.getIdentifier("message", "id", "android")
+ val messageView = dialog.findViewById(messageId)
+ messageView?.setTextColor(0xFFFFFFFF.toInt())
+ }
+ dialog.show()
+ }
+
+ private fun clearCacheAndReset(context: Context) {
+ scope.launch(Dispatchers.IO) {
+ try {
+ val pyoncordDir = getPyoncordDirectory(context)
+ val bundleFile = File(pyoncordDir, "bundle.js")
+ val configFile = File(pyoncordDir, "loader.json")
+
+ bundleFile.delete()
+
+ if (configFile.exists()) {
+ val config = JSONObject(configFile.readText())
+ val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
+ customLoadUrl.put("enabled", false)
+ customLoadUrl.put("url", "http://localhost:4040/shiggycord.js")
+ config.put("customLoadUrl", customLoadUrl)
+ configFile.writeText(config.toString())
+ }
+
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Cache cleared successfully", Toast.LENGTH_SHORT).show()
+ reloadApp()
+ }
+
+ } catch (e: Exception) {
+ Log.e("Error clearing cache: ${e.message}")
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Failed to clear cache", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ }
+
+ private fun disableCustomBundle(context: Context) {
+ try {
+ val configFile = File(getPyoncordDirectory(context), "loader.json")
+ val config = if (configFile.exists()) {
+ JSONObject(configFile.readText())
+ } else {
+ JSONObject().apply {
+ put("loadReactDevTools", false)
+ }
+ }
+
+ val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
+ customLoadUrl.put("enabled", false)
+ customLoadUrl.put("url", "http://localhost:4040/shiggycord.js")
+ config.put("customLoadUrl", customLoadUrl)
+
+ configFile.writeText(config.toString())
+ Toast.makeText(context, "Custom bundle disabled", Toast.LENGTH_SHORT).show()
+ reloadApp()
+ } catch (e: Exception) {
+ Toast.makeText(context, "Failed to disable custom bundle", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun isSafeModeEnabled(context: Context): Boolean {
+ return try {
+ val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
+ if (!settingsFile.exists()) return false
+
+ val json = JSONObject(settingsFile.readText())
+ json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false
+ } catch (e: Exception) {
+ Log.e("Error checking safe mode: ${e.message}")
+ false
+ }
+ }
+
+ private fun toggleSafeMode(context: Context) {
+ try {
+ val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
+ val themeFile = File(context.filesDir, "vd_mmkv/VENDETTA_THEMES")
+
+ settingsFile.parentFile?.mkdirs()
+
+ val settings = if (settingsFile.exists()) {
+ JSONObject(settingsFile.readText())
+ } else {
+ JSONObject()
+ }
+
+ val safeMode = settings.optJSONObject("safeMode") ?: JSONObject()
+ val currentState = safeMode.optBoolean("enabled", false)
+ val newState = !currentState
+
+ safeMode.put("enabled", newState)
+
+ if (newState && themeFile.exists()) {
+ val theme = JSONObject(themeFile.readText())
+ val themeId = theme.optString("id")
+ if (themeId.isNotEmpty()) {
+ safeMode.put("currentThemeId", themeId)
+ themeFile.delete()
+ }
+ }
+
+ settings.put("safeMode", safeMode)
+ settingsFile.writeText(settings.toString())
+
+ Toast.makeText(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}", Toast.LENGTH_SHORT).show()
+
+ reloadApp()
+
+ } catch (e: Exception) {
+ Log.e("Error toggling safe mode: ${e.message}")
+ showError(context, "Failed to toggle safe mode", e.message)
+ }
+ }
+
+ private fun refetchBundle(context: Context) {
+ try {
+ val pyoncordDir = getPyoncordDirectory(context)
+ val bundleFile = File(pyoncordDir, "bundle.js")
+ val backupFile = File(pyoncordDir, "bundle.js.backup")
+
+ if (bundleFile.exists()) {
+ backupFile.delete()
+ bundleFile.renameTo(backupFile)
+ Log.e("Bundle moved to backup")
+ }
+
+ reloadApp()
+
+ } catch (e: Exception) {
+ Log.e("Error refetching bundle: ${e.message}")
+ showError(context, "Failed to refetch bundle", e.message)
+ }
+ }
+
+ private fun resetBundle(context: Context) {
+ try {
+ val pyoncordDir = getPyoncordDirectory(context)
+ val bundleFile = File(pyoncordDir, "bundle.js")
+ val configFile = File(pyoncordDir, "loader_config.json")
+
+ bundleFile.delete()
+
+ if (configFile.exists()) {
+ val config = JSONObject(configFile.readText())
+ config.put("customLoadUrlEnabled", false)
+ config.put("customLoadUrl", "http://localhost:4040/ShiggyCord.js")
+ configFile.writeText(config.toString())
+ }
+
+ reloadApp()
+ } catch (e: Exception) {
+ Log.e("Error resetting bundle: ${e.message}")
+ showError(context, "Failed to reset bundle", e.message)
+ }
+ }
+
+ private fun validateAndLoadBundle(context: Context, urlString: String) {
+ scope.launch(Dispatchers.IO) {
+ try {
+ val url = URL(urlString)
+ if (url.protocol != "http" && url.protocol != "https") {
+ withContext(Dispatchers.Main) {
+ showError(context, "Invalid URL", "URL must start with http:// or https://")
+ }
+ return@launch
+ }
+
+ val connection = url.openConnection() as HttpURLConnection
+ connection.requestMethod = "GET"
+ connection.connectTimeout = 10000
+ connection.readTimeout = 10000
+
+ val responseCode = connection.responseCode
+ if (responseCode != 200) {
+ withContext(Dispatchers.Main) {
+ showError(context, "Error", "Server returned error $responseCode")
+ }
+ return@launch
+ }
+
+ val contentType = connection.contentType
+ if (contentType != null && !contentType.contains("javascript") && !contentType.contains("text")) {
+ withContext(Dispatchers.Main) {
+ showError(context, "Invalid Content", "URL must point to a JavaScript file")
+ }
+ return@launch
+ }
+
+ withContext(Dispatchers.Main) {
+ setCustomBundleURL(context, urlString)
+ }
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ showError(context, "Connection Error", "Could not reach URL: ${e.message}")
+ }
+ }
+ }
+ }
+
+ private fun setCustomBundleURL(context: Context, url: String) {
+ try {
+ val pyoncordDir = getPyoncordDirectory(context)
+ val configFile = File(pyoncordDir, "loader.json")
+
+ val config = if (configFile.exists()) {
+ JSONObject(configFile.readText())
+ } else {
+ JSONObject().apply {
+ put("loadReactDevTools", false)
+ }
+ }
+
+ val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
+ customLoadUrl.put("enabled", true)
+ customLoadUrl.put("url", url)
+ config.put("customLoadUrl", customLoadUrl)
+
+ configFile.writeText(config.toString())
+
+ File(pyoncordDir, "bundle.js").delete()
+
+ Toast.makeText(context, "Custom bundle set", Toast.LENGTH_SHORT).show()
+
+ reloadApp()
+ } catch (e: Exception) {
+ Log.e("Error setting custom bundle URL: ${e.message}")
+ showError(context, "Failed to save configuration", e.message)
+ }
+ }
+
+ private fun getPyoncordDirectory(context: Context): File {
+ val dir = File(context.filesDir, "pyoncord")
+ if (!dir.exists()) {
+ dir.mkdirs()
+ }
+ return dir
+ }
+
+ override fun onContext(context: Context) {
+ }
+}
diff --git a/app/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt
new file mode 100644
index 0000000..03d0601
--- /dev/null
+++ b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt
@@ -0,0 +1,80 @@
+package GoonXposed.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 GoonXposed.xposed.BuildConfig
+import GoonXposed.xposed.Constants
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils.Companion.JSON
+import GoonXposed.xposed.modules.HookScriptLoaderModule.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 scriptLoader = classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance\$loadJSBundle$1")
+ ?: 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(
+ scriptLoader!!.method(
+ "loadScriptFromFile",
+ String::class.java,
+ String::class.java,
+ Boolean::class.javaPrimitiveType
+ ), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2])
+ )
+
+ delete()
+ }
+ }
+ }
+
+ val hook = MethodHookBuilder().run {
+ before {
+ setGlobalVariable(param, GLOBAL_NAME, getPayloadString())
+ }
+
+ build()
+ }
+
+ listOf(catalystInstance, scriptLoader).forEach { if (it != null) hook(it, hook) }
+ }
+
+ private fun hook(instance: Class<*>, hook: XC_MethodHook) {
+ instance.method(
+ "loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType,
+ ).hook(hook)
+
+ instance.method(
+ "loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType
+ ).hook(hook)
+ }
+}
diff --git a/app/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt
new file mode 100644
index 0000000..d8cc257
--- /dev/null
+++ b/app/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt
@@ -0,0 +1,152 @@
+package GoonXposed.xposed.modules
+
+import android.app.Activity
+import android.app.AlertDialog
+import android.content.Context
+import android.util.AtomicFile
+import android.widget.Toast
+import androidx.core.util.writeBytes
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import GoonXposed.xposed.Constants
+import GoonXposed.xposed.Module
+import GoonXposed.xposed.Utils
+import GoonXposed.xposed.Utils.Companion.JSON
+import GoonXposed.xposed.Utils.Companion.reloadApp
+import GoonXposed.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
+import java.lang.ref.WeakReference
+
+@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.
+ */
+object UpdaterModule : Module() {
+ private lateinit var config: LoaderConfig
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var lastActivity: WeakReference? = null
+
+ private lateinit var cacheDir: File
+ private lateinit var bundle: File
+ private lateinit var etag: File
+
+ 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/OTKUSteyler/GoonManager/releases/latest/download/gooncord.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())
+ }
+
+ 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("GoonCord 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)
+ showErrorDialog(e)
+ }
+ }
+
+ override fun onActivity(activity: Activity) {
+ lastActivity = WeakReference(activity)
+ }
+
+ fun showErrorDialog(e: Throwable) {
+ val activity = lastActivity?.get() ?: return
+ }
+
+ fun resetLoaderConfig(context: Context) {
+ val filesDir = File(context.dataDir, Constants.FILES_DIR)
+ val config = File(filesDir, CONFIG_FILE)
+ if (config.exists()) config.delete()
+ }
+}
diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..bff2089
--- /dev/null
+++ b/app/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ GoonXposed
+