From 8d1d6303d3185ac7eed2480fffde5fc25c9f8f7b Mon Sep 17 00:00:00 2001 From: OTKUSteyler <90369564+OTKUSteyler@users.noreply.github.com> Date: Mon, 24 Nov 2025 17:50:15 +0100 Subject: [PATCH] Add files via upload --- app/app/build.gradle.kts | 51 + app/app/src/main/AndroidManifest.xml | 21 + app/app/src/main/assets/xposed_init | 1 + .../GoonXposed/xposed/xposed/Constants.kt | 19 + .../kotlin/GoonXposed/xposed/xposed/Main.kt | 107 ++ .../kotlin/GoonXposed/xposed/xposed/Module.kt | 183 +++ .../kotlin/GoonXposed/xposed/xposed/Utils.kt | 58 + .../xposed/xposed/modules/CacheModule.kt | 270 +++++ .../xposed/modules/FixResourcesModule.kt | 23 + .../xposed/modules/HookScriptLoaderModule.kt | 121 ++ .../xposed/xposed/modules/LogBoxModule.kt | 1051 +++++++++++++++++ .../xposed/modules/PayloadGlobalModule.kt | 80 ++ .../xposed/xposed/modules/PluginsModule.kt | 124 ++ .../xposed/xposed/modules/UpdaterModule.kt | 155 +++ .../xposed/modules/appearance/FontsModule.kt | 228 ++++ .../modules/appearance/SysColorsModule.kt | 43 + .../xposed/modules/appearance/ThemesModule.kt | 159 +++ .../bridge/AdditionalBridgeMethodsModule.kt | 94 ++ .../xposed/modules/bridge/BridgeModule.kt | 157 +++ .../no_track/BlockCrashReportingModule.kt | 50 + .../no_track/BlockDeepLinksTrackingModule.kt | 25 + .../kotlin/io/github/revenge/xposed/Utils.kt | 32 + .../xposed/modules/FixResourcesModule.kt | 23 + .../xposed/modules/HookScriptLoaderModule.kt | 121 ++ .../revenge/xposed/modules/LogBoxModule.kt | 601 ++++++++++ .../xposed/modules/PayloadGlobalModule.kt | 80 ++ .../revenge/xposed/modules/UpdaterModule.kt | 152 +++ app/app/src/main/res/values/strings.xml | 3 + 28 files changed, 4032 insertions(+) create mode 100644 app/app/build.gradle.kts create mode 100644 app/app/src/main/AndroidManifest.xml create mode 100644 app/app/src/main/assets/xposed_init create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/Constants.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/Main.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/Module.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/Utils.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/CacheModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/FixResourcesModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/HookScriptLoaderModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/LogBoxModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/PayloadGlobalModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/PluginsModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/UpdaterModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/FontsModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/SysColorsModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/appearance/ThemesModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/bridge/BridgeModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt create mode 100644 app/app/src/main/kotlin/GoonXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt create mode 100644 app/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt create mode 100644 app/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt create mode 100644 app/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt create mode 100644 app/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt create mode 100644 app/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt create mode 100644 app/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt create mode 100644 app/app/src/main/res/values/strings.xml 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 +