From 5f26b750393c3aa3970bc2b643daa9e9692c65e7 Mon Sep 17 00:00:00 2001 From: kmmiio99o <164557248+kmmiio99o@users.noreply.github.com> Date: Fri, 7 Nov 2025 22:19:26 +0100 Subject: [PATCH] Pull KettuXposed and rename it fully --- app/build.gradle.kts | 40 +- app/src/main/AndroidManifest.xml | 9 - .../xposed}/xposed/Constants.kt | 3 +- .../kotlin/ShiggyXposed/xposed/xposed/Main.kt | 107 ++++ .../xposed}/xposed/Module.kt | 143 +++-- .../ShiggyXposed/xposed/xposed/Utils.kt | 58 ++ .../xposed/xposed/modules/CacheModule.kt | 270 ++++++++++ .../xposed/modules/FixResourcesModule.kt | 23 + .../xposed/modules/HookScriptLoaderModule.kt | 107 ++-- .../xposed/xposed/modules/LogBoxModule.kt | 355 ++++++++++++ .../xposed/modules/PayloadGlobalModule.kt | 79 +++ .../xposed/xposed/modules/PluginsModule.kt | 124 +++++ .../xposed/xposed/modules/UpdaterModule.kt | 152 ++++++ .../xposed/modules/appearance/FontsModule.kt | 230 ++++++++ .../modules/appearance/SysColorsModule.kt | 25 +- .../xposed/modules/appearance/ThemesModule.kt | 90 ++-- .../bridge/AdditionalBridgeMethodsModule.kt | 94 ++++ .../xposed/modules/bridge/BridgeModule.kt | 149 +++++ .../no_track/BlockCrashReportingModule.kt | 34 ++ .../no_track/BlockDeepLinksTrackingModule.kt | 25 + .../kotlin/io/github/revenge/xposed/Main.kt | 70 --- .../kotlin/io/github/revenge/xposed/Utils.kt | 32 -- .../xposed/modules/FixResourcesModule.kt | 21 - .../revenge/xposed/modules/LogBoxModule.kt | 510 ------------------ .../xposed/modules/PayloadGlobalModule.kt | 108 ---- .../revenge/xposed/modules/UpdaterModule.kt | 148 ----- .../xposed/modules/appearance/FontsModule.kt | 271 ---------- .../bridge/AdditionalBridgeMethodsModule.kt | 52 -- .../xposed/modules/bridge/BridgeModule.kt | 208 ------- settings.gradle.kts | 2 +- 30 files changed, 1895 insertions(+), 1644 deletions(-) rename app/src/main/kotlin/{io/github/revenge => ShiggyXposed/xposed}/xposed/Constants.kt (91%) create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt rename app/src/main/kotlin/{io/github/revenge => ShiggyXposed/xposed}/xposed/Module.kt (51%) create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/CacheModule.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/FixResourcesModule.kt rename app/src/main/kotlin/{io/github/revenge => ShiggyXposed/xposed}/xposed/modules/HookScriptLoaderModule.kt (51%) create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PayloadGlobalModule.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PluginsModule.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt rename app/src/main/kotlin/{io/github/revenge => ShiggyXposed/xposed}/xposed/modules/appearance/SysColorsModule.kt (59%) rename app/src/main/kotlin/{io/github/revenge => ShiggyXposed/xposed}/xposed/modules/appearance/ThemesModule.kt (67%) create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/BridgeModule.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt create mode 100644 app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/Main.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/Utils.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt delete mode 100644 app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b9a4175..c76931e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,10 +10,10 @@ android { defaultConfig { applicationId = "ShiggyXposed.xposed" - minSdk = 22 + minSdk = 24 targetSdk = 36 - versionCode = 1051 - versionName = "1.0.51" + versionCode = 1100 + versionName = "1.1.0" } buildTypes { @@ -28,31 +28,23 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true } - kotlinOptions { jvmTarget = "17" } - buildFeatures { buildConfig = true } -} - -repositories { - mavenCentral() - google() - maven("https://jitpack.io") } dependencies { compileOnly("de.robv.android.xposed:api:82") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") - implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0") - implementation("io.ktor:ktor-client-core:3.3.0") - implementation("io.ktor:ktor-client-cio:3.3.0") - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.11.0") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + 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") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a969094..40f8598 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,18 +1,9 @@ - - - - () + + /** + * 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.add(PayloadGlobalModule(modules)) + } + + override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) { + for (module in modules) module.onInit(startupParam) + } + + override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with(param) { + 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/src/main/kotlin/io/github/revenge/xposed/Module.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Module.kt similarity index 51% rename from app/src/main/kotlin/io/github/revenge/xposed/Module.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/Module.kt index 8813346..d813e48 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/Module.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Module.kt @@ -7,25 +7,28 @@ 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 -import kotlinx.serialization.json.JsonObjectBuilder data class AppInfo( - val name: String, - val packageName: String, - val version: String, - val versionCode: Long, + 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) {} + /** + * 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. */ + /** + * Called during Zygote initialization. + */ open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {} /** @@ -38,11 +41,10 @@ abstract class Module { open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {} /** - * Called after [Context] has been attached to a [android.content.ContextWrapper] via - * [android.content.ContextWrapper.attachBaseContext]. + * 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. + * 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. */ @@ -55,25 +57,22 @@ abstract class Module { * * 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. + * 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. + * 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() + 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, + packageManager.getApplicationLabel(applicationInfo).toString(), + packageName, + pInfo.versionName ?: versionCode.toString(), + versionCode, ) } @@ -86,22 +85,21 @@ abstract class Module { if (!this.isFile) this.deleteRecursively() } - protected fun Class<*>.method(name: String, vararg params: Class<*>?): Method = - getDeclaredMethod(name, *params).apply { isAccessible = true } + 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 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(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()) + hook(MethodHookBuilder().apply(block).build()) protected fun Class<*>.hookMethod( - name: String, - vararg params: Class<*>?, - block: MethodHookBuilder.() -> Unit + name: String, vararg params: Class<*>?, block: MethodHookBuilder.() -> Unit ): XC_MethodHook.Unhook = method(name, *params).hook(MethodHookBuilder().apply(block).build()) protected class MethodHookBuilder { @@ -116,36 +114,33 @@ abstract class Module { afterBlock = block } - fun build(): XC_MethodHook = - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - val b = beforeBlock - if (b != null) { - val scope = - HookScope( - param = param, - 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) - } - } + 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) + } + } + } } /** @@ -159,23 +154,19 @@ abstract class Module { * @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 + 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. + * 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 thisObject: Any? get() = param.thisObject - val args: Array - get() = param.args + val args: Array get() = param.args var result: Any? get() = param.result diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt new file mode 100644 index 0000000..8957912 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt @@ -0,0 +1,58 @@ +package ShiggyXposed.xposed + +import android.app.AlertDialog +import android.app.AndroidAppHelper +import android.content.Context +import android.content.Intent +import ShiggyXposed.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("ShiggyCord 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/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/CacheModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/CacheModule.kt new file mode 100644 index 0000000..5c81d6d --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/CacheModule.kt @@ -0,0 +1,270 @@ +package ShiggyXposed.xposed.modules + +import android.content.Context +import android.util.AtomicFile +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.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. + */ +class CacheModule : Module() { + private companion object { + const val CACHE_DIR = "Shiggy" + + const val MODULES_CACHE_PREFIX = "modules" + const val ASSETS_CACHE_PREFIX = "assets" + } + + private lateinit var modulesCache: ModulesCache + private lateinit var assetsCache: AssetsCache + + override fun onContext(context: Context) = with(context) { + val ShiggyCacheDir = File(cacheDir, CACHE_DIR).apply { asDir() } + val (_, _, _, versionCode) = getAppInfo() + + val modulesCacheFile = File( + ShiggyCacheDir, "$MODULES_CACHE_PREFIX.$versionCode" + ).apply { asFile() } + + val assetsCacheFile = File( + ShiggyCacheDir, "$ASSETS_CACHE_PREFIX.$versionCode" + ).apply { asFile() } + + BridgeModule.registerMethod("Shiggy.caches.modules.read") { + if (::modulesCache.isInitialized) modulesCache.toMap() else ModulesCache.loadFromFileOrNull(modulesCacheFile) + ?.let { + modulesCache = it + it.toMap() + } + } + + BridgeModule.registerMethod("Shiggy.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("Shiggy.caches.assets.read") { + if (::assetsCache.isInitialized) assetsCache.toMap() else AssetsCache.loadFromFileOrNull(assetsCacheFile) + ?.let { + assetsCache = it + it.toMap() + } + } + + BridgeModule.registerMethod("Shiggy.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/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/FixResourcesModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/FixResourcesModule.kt new file mode 100644 index 0000000..23c7659 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/FixResourcesModule.kt @@ -0,0 +1,23 @@ +package ShiggyXposed.xposed.modules + +import android.content.res.Resources +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Constants.Companion.TARGET_PACKAGE +import ShiggyXposed.xposed.Module + +/** + * Hooks [Resources.getIdentifier] to fix resource package name mismatch. + */ +class FixResourcesModule : Module() { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + if (packageName != TARGET_PACKAGE) { + Resources::class.java.hookMethod( + "getIdentifier", String::class.java, String::class.java, String::class.java + ) { + before { + if (args[2] == packageName) args[2] = TARGET_PACKAGE + } + } + } + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/HookScriptLoaderModule.kt similarity index 51% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/HookScriptLoaderModule.kt index bf57f07..806502c 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/HookScriptLoaderModule.kt @@ -1,27 +1,28 @@ package ShiggyXposed.xposed.modules -import ShiggyXposed.xposed.Constants -import ShiggyXposed.xposed.Module -import ShiggyXposed.xposed.Utils.Log -import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR 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 ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.HookStateHolder +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking import java.io.File import java.lang.reflect.Method -import kotlinx.coroutines.runBlocking /** * 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. + * Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files directory. * - * The main bundle should be placed in the [Constants.CACHE_DIR] directory named - * [Constants.MAIN_SCRIPT_FILE]. If the bundle file does not exist, it will attempt to load - * `assets://revenge.bundle` from the module's assets. + * 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. */ class HookScriptLoaderModule : Module() { private lateinit var preloadsDir: File @@ -38,39 +39,35 @@ class HookScriptLoaderModule : Module() { 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() } + 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() } + preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() } + mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() } - listOf( - "com.facebook.react.runtime.ReactInstance$1", - // TODO: Remove once Discord fully switches to Bridgeless - "com.facebook.react.bridge.CatalystInstanceImpl" - ) - .mapNotNull { classLoader.safeLoadClass(it) } - .forEach { hook(it) } - } + listOf( + "com.facebook.react.runtime.ReactInstance$1", + // TODO: Remove once Discord fully switches to Bridgeless + "com.facebook.react.bridge.CatalystInstanceImpl" + ).mapNotNull { classLoader.safeLoadClass(it) } + .forEach { hook(it) } + } private fun hook(instance: Class<*>) { - val loadScriptFromAssets = - instance.method( - "loadScriptFromAssets", - AssetManager::class.java, - String::class.java, - Boolean::class.javaPrimitiveType - ) + val loadScriptFromAssets = instance.method( + "loadScriptFromAssets", + AssetManager::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ) - val loadScriptFromFile = - instance.method( - "loadScriptFromFile", - String::class.java, - String::class.java, - Boolean::class.javaPrimitiveType - ) + val loadScriptFromFile = instance.method( + "loadScriptFromFile", + String::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ) loadScriptFromAssets.hook { before { @@ -87,43 +84,45 @@ class HookScriptLoaderModule : Module() { } } - private fun HookScope.runCustomScripts( - loadScriptFromFile: Method, - loadScriptFromAssets: Method - ) { + private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) { Log.i("Running custom scripts...") - // TODO: Is there a better way to do this? - runBlocking { UpdaterModule.job?.join() } + 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) + loadScriptFromFile, + thisObject, + arrayOf(file.absolutePath, file.absolutePath, loadSynchronously) ) Unit } try { - - preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile) + 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) + if (!::resources.isInitialized) resources = + XModuleResources.createInstance(modulePath, null) XposedBridge.invokeOriginalMethod( - loadScriptFromAssets, - thisObject, - arrayOf(resources.assets, "assets://bundle.js", loadSynchronously) + loadScriptFromAssets, + thisObject, + arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously) ) } } catch (e: Throwable) { diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt new file mode 100644 index 0000000..ac5a9e0 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt @@ -0,0 +1,355 @@ +package ShiggyXposed.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 ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils +import ShiggyXposed.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 ShiggyXposed.xposed.Utils.Companion.reloadApp + +class 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" + ) + + AlertDialog.Builder(context) + .setTitle("ShiggyXposed Recovery Menu") + .setItems(options) { _, which -> + handleMenuSelection(context, which) + } + .setNegativeButton("Cancel", null) + .show() + } + + private fun handleMenuSelection(context: Context, index: Int) { + when (index) { + 0 -> toggleSafeMode(context) + 1 -> confirmAction(context, "refetch the bundle") { refetchBundle(context) } + 2 -> loadCustomBundle(context) + 3 -> reloadApp() + } + } + + private fun confirmAction(context: Context, actionText: String, action: () -> Unit) { + AlertDialog.Builder(context) + .setTitle("Confirm Action") + .setMessage("Are you sure you want to $actionText?") + .setPositiveButton("Confirm") { _, _ -> action() } + .setNegativeButton("Cancel", null) + .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() + + // Reset config + 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 loadCustomBundle(context: Context) { + val input = EditText(context).apply { + hint = "http://localhost:4040/ShiggyCord.js" + inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI + } + + AlertDialog.Builder(context) + .setTitle("Load Custom Bundle") + .setMessage("Enter the URL for your custom bundle:") + .setView(input) + .setPositiveButton("Load") { _, _ -> + val urlString = input.text.toString().trim() + if (urlString.isEmpty()) { + showError(context, "Invalid URL", "Please enter a URL") + return@setPositiveButton + } + + validateAndLoadBundle(context, urlString) + } + .setNegativeButton("Cancel", null) + .show() + } + + 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 + } + + // Save custom bundle URL + 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_config.json") + + val config = if (configFile.exists()) { + JSONObject(configFile.readText()) + } else { + JSONObject() + } + + config.put("customLoadUrlEnabled", true) + config.put("customLoadUrl", url) + 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 + } + + private fun showError(context: Context, title: String, message: String?) { + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message ?: "An unknown error occurred") + .setPositiveButton("OK", null) + .show() + } + + override fun onContext(context: Context) { + } +} diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PayloadGlobalModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PayloadGlobalModule.kt new file mode 100644 index 0000000..bc0a7b2 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PayloadGlobalModule.kt @@ -0,0 +1,79 @@ +package ShiggyXposed.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 ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.File + +// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule if needed +class PayloadGlobalModule(private val modules: List) : Module() { + private companion object { + const val GLOBAL_NAME = "__PYON_LOADER__" + } + + private fun getPayloadString(): String = JSON.encodeToString( + buildJsonObject { + put("loaderName", Constants.LOADER_NAME) + put("loaderVersion", BuildConfig.VERSION_NAME) + @Suppress("DEPRECATION") for (module in modules) module.buildPayload(this) + }) + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val catalystInstance = classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl") + val reactInstance = classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1") + + val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit = { param, key, json -> + runCatching { + // Attempt old CatalystInstanceImpl method + catalystInstance!!.method( + "setGlobalVariable", String::class.java, String::class.java + ).invoke(param.thisObject, key, json) + }.onFailure { + // Failed (likely because it's a stub), use a workaround + val preloadsDir = File("${appInfo.dataDir}/${Constants.FILES_DIR}", PRELOADS_DIR) + File(preloadsDir, "rv_globals_$key.js").apply { + writeText("this[${JSON.encodeToString(key)}]=$json") + + XposedBridge.invokeOriginalMethod( + reactInstance!!.method( + "loadScriptFromFile", + String::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2]) + ) + + delete() + } + } + } + + val hook = MethodHookBuilder().run { + before { + setGlobalVariable(param, GLOBAL_NAME, getPayloadString()) + } + + build() + } + + listOf(catalystInstance, reactInstance).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/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PluginsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PluginsModule.kt new file mode 100644 index 0000000..8da4acd --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PluginsModule.kt @@ -0,0 +1,124 @@ +package ShiggyXposed.xposed.modules + +import android.content.Context +import android.util.AtomicFile +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.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/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt new file mode 100644 index 0000000..eb4f668 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt @@ -0,0 +1,152 @@ +package ShiggyXposed.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 ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.Utils.Companion.reloadApp +import ShiggyXposed.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/kmmiio99o/ShiggyCord/releases/latest/download/shiggycord.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("ShiggyCord 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/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt new file mode 100644 index 0000000..f751a97 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt @@ -0,0 +1,230 @@ +package ShiggyXposed.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 ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.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, +) + +class FontsModule : Module() { + private companion object { + val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") + val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") + const val FONTS_ASSET_PATH = "fonts/" + } + + private lateinit var fontsDir: File + private lateinit var fontsDownloadsDir: File + 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/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/SysColorsModule.kt similarity index 59% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/SysColorsModule.kt index 121c86a..3a40a6b 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/SysColorsModule.kt @@ -1,11 +1,11 @@ package ShiggyXposed.xposed.modules.appearance -import ShiggyXposed.xposed.Module import android.R.color import android.app.AndroidAppHelper import android.content.Context import android.os.Build import androidx.core.content.ContextCompat +import ShiggyXposed.xposed.Module import kotlinx.serialization.json.* class SysColorsModule : Module() { @@ -19,22 +19,19 @@ class SysColorsModule : Module() { builder.apply { put("isSysColorsSupported", isSupported()) - if (isSupported()) - putJsonObject("sysColors") { - for (accent in accents) putJsonArray(accent) { - for (shade in shades) { - val colorName = "system_" + accent + "_" + shade + 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 } + val colorResourceId = runCatching { + color::class.java.getField(colorName).getInt(null) + }.getOrElse { 0 } - add(convertToColor(colorResourceId)) - } - } + add(convertToColor(colorResourceId)) } + } + } } } diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/ThemesModule.kt similarity index 67% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/ThemesModule.kt index 87f372a..a1dbed9 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/ThemesModule.kt @@ -1,32 +1,38 @@ package ShiggyXposed.xposed.modules.appearance -import ShiggyXposed.xposed.Constants -import ShiggyXposed.xposed.Module -import ShiggyXposed.xposed.Utils.Companion.JSON import android.content.Context import android.content.res.Resources import androidx.core.graphics.toColorInt import de.robv.android.xposed.callbacks.XC_LoadPackage -import java.io.File +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.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 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 + 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) +@Serializable +data class Theme( + val id: String, val selected: Boolean, val data: ThemeData +) class ThemesModule : Module() { private lateinit var param: XC_LoadPackage.LoadPackageParam @@ -48,9 +54,7 @@ class ThemesModule : Module() { } private fun String.fromScreamingSnakeToCamelCase() = - this.split("_").joinToString("") { it -> - it.lowercase().replaceFirstChar { it.uppercase() } - } + this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } } override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { param = packageParam @@ -66,10 +70,7 @@ class ThemesModule : Module() { } private fun getTheme(): Theme? { - val themeFile = - File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { - asFile() - } + val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { asFile() } if (!themeFile.isValidish()) return null return try { @@ -106,34 +107,28 @@ class ThemesModule : Module() { // 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 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 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) + 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] - } + if (rawColorMap[name] != null) result = rawColorMap[name] + } - build() - } + build() + } getColorCompat.hook(patch) getColorCompatLegacy.hook(patch) @@ -153,8 +148,13 @@ class ThemesModule : Module() { private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) { try { themeClass.getDeclaredMethod(methodName).let { method -> - method.hook { before { result = themeValue } } + method.hook { + before { + result = themeValue + } + } } - } catch (_: NoSuchMethodException) {} + } catch (_: NoSuchMethodException) { + } } } diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt new file mode 100644 index 0000000..fd46843 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt @@ -0,0 +1,94 @@ +package ShiggyXposed.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 ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils +import java.io.File + +class 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("ShiggyCord Error") + .setMessage( + """ + ShiggyCord: $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/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/BridgeModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/BridgeModule.kt new file mode 100644 index 0000000..8cd801b --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/BridgeModule.kt @@ -0,0 +1,149 @@ +package ShiggyXposed.xposed.modules.bridge + +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.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" + * } + * ``` + */ +class BridgeModule : Module() { + private lateinit var readableMapGetString: Method + private lateinit var readableMapToHashMap: Method + private lateinit var argumentsMakeNative: Method + + companion object { + private const val CALL_DATA_KEY = "Shiggy" + private const val METHOD_NAME_KEY = "method" + private const val METHOD_ARGS_KEY = "args" + + private val methods: MutableMap = mutableMapOf("Shiggy.info" to { + mapOf( + "name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE + ) + }, "Shiggy.test" to { + mapOf( + "string" to "string", + "number" to 7256, + "array" to listOf("testing", 527737, listOf(true)), + "object" to mapOf("nested" to true), + "boolean" to false, + "args" to it, + ) + }) + + /** + * Registers a bridge method that can be called from JavaScript. + * + * If the method is already registered, it will be overwritten. + * + * @param name The name of the method to register. + * @param callback The callback to invoke when the method is called. + */ + fun registerMethod(name: String, callback: BridgeMethodCallback) { + if (methods.containsKey(name)) Log.w("Bridge method already exists and will be overridden: $name") + methods[name] = callback + } + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments") + val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap") + val promise = classLoader.loadClass("com.facebook.react.bridge.Promise") + + val promiseResolve = promise.method("resolve", Object::class.java) + argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java) + readableMapGetString = readableMap.method("getString", String::class.java) + readableMapToHashMap = readableMap.method("toHashMap") + + classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager") + .hookMethod("getBBox", Double::class.javaObjectType, readableMap) { + before { + callBridgeMethod(readableMapToHashMap(args[1]!!))?.let { result = it.toNativeObject() } + } + } + + classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule") + .hookMethod("readAsDataURL", readableMap, promise) { + before { + val (readableMap, promise) = args + callBridgeMethod(readableMapToHashMap(readableMap!!))?.let { + promiseResolve.invoke(promise, it.toNativeObject()) + result = null + } + } + } + + return@with + } + + private fun Any?.toNativeObject(): Any? = argumentsMakeNative.invoke( + null, when (this) { + Unit -> null + else -> this + } + ) + + private fun readableMapToHashMap(map: Any): HashMap { + @Suppress("UNCHECKED_CAST") return readableMapToHashMap.invoke(map) as HashMap + } + + private fun callBridgeMethod(hashMap: HashMap): Map? = try { + val (method, args) = getBridgeCallData(hashMap) ?: return null + val ret = method(args).toNativeObject() + mapOf("result" to ret) + } catch (e: Throwable) { + mapOf("error" to e.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/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt new file mode 100644 index 0000000..fb70a90 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt @@ -0,0 +1,34 @@ +package ShiggyXposed.xposed.modules.no_track + +import android.content.Context +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Module +import ShiggyXposed.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. + */ +class BlockCrashReportingModule : Module() { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val crashReportingClass = classLoader.safeLoadClass("com.discord.crash_reporting.CrashReporting") + crashReportingClass?.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/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt new file mode 100644 index 0000000..df5ecb2 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt @@ -0,0 +1,25 @@ +package ShiggyXposed.xposed.modules.no_track + +import android.content.Context +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log + +/** + * Hooks Discord's deep links tracking to disable AppsFlyer initialization. + */ +class 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/src/main/kotlin/io/github/revenge/xposed/Main.kt b/app/src/main/kotlin/io/github/revenge/xposed/Main.kt deleted file mode 100644 index 38118cc..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/Main.kt +++ /dev/null @@ -1,70 +0,0 @@ -package ShiggyXposed.xposed - -import ShiggyXposed.xposed.modules.* -import ShiggyXposed.xposed.modules.appearance.FontsModule -import ShiggyXposed.xposed.modules.appearance.SysColorsModule -import ShiggyXposed.xposed.modules.appearance.ThemesModule -import ShiggyXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule -import ShiggyXposed.xposed.modules.bridge.BridgeModule -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 - -class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit { - private var hooked = false - private val modules = - mutableListOf( - HookScriptLoaderModule(), - BridgeModule(), - AdditionalBridgeMethodsModule(), - UpdaterModule(), - FixResourcesModule(), - LogBoxModule(), - FontsModule(), - ThemesModule(), - SysColorsModule() - ) - - init { - modules.add(PayloadGlobalModule(modules)) - } - - override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) { - for (module in modules) module.onInit(startupParam) - } - - override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = - with(param) { - if (hooked) return - - val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY) - - ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) { - after { this@Main.onContext(args[0] as Context) } - } - - reactActivity.hookMethod("onCreate", Bundle::class.java) { - after { this@Main.onActivity(thisObject as Activity) } - } - - 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/src/main/kotlin/io/github/revenge/xposed/Utils.kt b/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt deleted file mode 100644 index a5c2e68..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt +++ /dev/null @@ -1,32 +0,0 @@ -package ShiggyXposed.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/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt deleted file mode 100644 index b0bee81..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ShiggyXposed.xposed.modules - -import ShiggyXposed.xposed.Constants.Companion.TARGET_PACKAGE -import ShiggyXposed.xposed.Module -import android.content.res.Resources -import de.robv.android.xposed.callbacks.XC_LoadPackage - -/** Hooks [Resources.getIdentifier] to fix resource package name mismatch. */ -class FixResourcesModule : Module() { - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = - with(packageParam) { - if (packageName != TARGET_PACKAGE) { - Resources::class.java.hookMethod( - "getIdentifier", - String::class.java, - String::class.java, - String::class.java - ) { before { if (args[2] == packageName) args[2] = TARGET_PACKAGE } } - } - } -} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt deleted file mode 100644 index e167f28..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt +++ /dev/null @@ -1,510 +0,0 @@ -package ShiggyXposed.xposed.modules - -import ShiggyXposed.xposed.Constants -import ShiggyXposed.xposed.Module -import ShiggyXposed.xposed.Utils -import ShiggyXposed.xposed.Utils.Companion.reloadApp -import ShiggyXposed.xposed.Utils.Log -import android.app.AlertDialog -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.view.View -import android.widget.Button -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.Switch -import android.widget.TextView -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.XposedHelpers -import de.robv.android.xposed.callbacks.XC_LoadPackage -import java.io.File -import kotlinx.serialization.json.* - -/** - * Replacement for the old recovery/dev dialog. Shows a dark-themed dialog on the main thread with: - * - Load from custom URL toggle + URL input (persists to files/pyoncord/loader.json) - * - Save & Reload, Delete bundle, Close actions - * - * This simplified version removes the Safe Mode toggle and the per-addon list. Save only persists - * the custom URL configuration. - */ -class LogBoxModule : Module() { - lateinit var packageParam: XC_LoadPackage.LoadPackageParam - lateinit var bridgeDevSupportManagerClass: Class<*> - - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = - with(packageParam) { - this@LogBoxModule.packageParam = packageParam - - try { - val dcdReactNativeHostClass = - classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") - bridgeDevSupportManagerClass = - classLoader.loadClass( - "com.facebook.react.devsupport.BridgeDevSupportManager" - ) - - val getUseDeveloperSupportMethod = - dcdReactNativeHostClass.methods.first { - it.name == "getUseDeveloperSupport" - } - val handleReloadJSMethod = - bridgeDevSupportManagerClass.methods.first { - it.name == "handleReloadJS" - } - val showDevOptionsDialogMethod = - bridgeDevSupportManagerClass.methods.first { - it.name == "showDevOptionsDialog" - } - val mReactInstanceDevHelperField = - XposedHelpers.findField( - bridgeDevSupportManagerClass, - "mReactInstanceDevHelper" - ) - - // Enable developer support (so shake/dev menu becomes available) - XposedBridge.hookMethod( - getUseDeveloperSupportMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - param.result = true - } - } - ) - - // Replace reload with app restart to avoid remote dev server dependency - XposedBridge.hookMethod( - handleReloadJSMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - reloadApp() - param.result = null - } - } - ) - - // Intercept showDevOptionsDialog and replace with our dialog - XposedBridge.hookMethod( - showDevOptionsDialogMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - try { - val mReactInstanceDevHelper = - mReactInstanceDevHelperField.get(param.thisObject) - val getCurrentActivityMethod = - mReactInstanceDevHelper.javaClass.methods.first { - it.name == "getCurrentActivity" - } - val context = - getCurrentActivityMethod.invoke( - mReactInstanceDevHelper - ) as - Context - - showRecoveryAlert(context) - } catch (ex: Exception) { - Log.e( - "Failed to show dev options dialog: ${ex.message}", - ex - ) - XposedBridge.log( - "ShiggyXposed: Failed to show dev options dialog: ${android.util.Log.getStackTraceString(ex)}" - ) - } - - // Prevent original menu from showing - param.result = null - } - } - ) - } catch (e: Exception) { - Log.e("Error in LogBoxModule.onLoad: ${e.message}", e) - XposedBridge.log( - "ShiggyXposed: LogBoxModule.onLoad error: ${android.util.Log.getStackTraceString(e)}" - ) - } - - return@with - } - - private fun showRecoveryAlert(context: Context) { - // Always perform UI operations on the main thread to avoid runtime exceptions - Handler(Looper.getMainLooper()).post { - try { - val themed = context - - // Prepare paths - val filesDir = - File(packageParam.appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() } - val configFile = File(filesDir, "loader.json") - val preloadsDir = - File(filesDir, HookScriptLoaderModule.PRELOADS_DIR).apply { mkdirs() } - - // Read existing loader.json (guarded) for custom URL - var customEnabled = false - var customUrl = "" - try { - if (configFile.exists()) { - val je = Utils.JSON.parseToJsonElement(configFile.readText()) - val custom = je.jsonObject["customLoadUrl"]?.jsonObject - if (custom != null) { - customEnabled = custom["enabled"]?.jsonPrimitive?.booleanOrNull ?: false - customUrl = custom["url"]?.jsonPrimitive?.contentOrNull ?: "" - } - } - } catch (t: Throwable) { - Log.e("Failed to parse loader.json: ${t.message}", t) - XposedBridge.log( - "ShiggyXposed: loader.json parse failed: ${android.util.Log.getStackTraceString(t)}" - ) - } - - // Build the dialog UI programmatically. - val padding = (themed.resources.displayMetrics.density * 16).toInt() - val container = - LinearLayout(themed).apply { - orientation = LinearLayout.VERTICAL - setPadding(padding, padding, padding, padding) - setBackgroundColor(0xFF121212.toInt()) - } - - // Helper to create a labeled switch row - fun addSwitchRow(labelText: String, checked: Boolean): Pair { - val rowLp = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - val row = - LinearLayout(themed).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = rowLp - } - - val tvLp = - LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - val tv = - TextView(themed).apply { - text = labelText - setTextColor(0xFFFFFFFF.toInt()) - textSize = 15f - layoutParams = tvLp - } - - val swLp = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - val sw = - Switch(themed).apply { - isChecked = checked - layoutParams = swLp - } - - row.addView(tv) - row.addView(sw) - container.addView(row) - return Pair(tv, sw) - } - - // Create rows: custom URL toggle - val customRow = addSwitchRow("Load from custom URL", false) - val customSwitch = customRow.second - - // Confirmation modal helper (keeps previous messaging/behavior) - fun showReloadConfirmModal(parentCtx: Context, turningOn: Boolean) { - try { - val dialogContainer = - LinearLayout(parentCtx).apply { - orientation = LinearLayout.VERTICAL - val pad = - (parentCtx.resources.displayMetrics.density * 16) - .toInt() - setPadding(pad, pad, pad, pad) - setBackgroundColor(0xFF121212.toInt()) - } - val message = - TextView(parentCtx).apply { - text = - if (turningOn) { - "All add-ons will be temporarily disabled upon reload." - } else { - "All add-ons will load normally." - } - setTextColor(0xFFFFFFFF.toInt()) - textSize = 14f - setPadding(0, 0, 0, 8) - } - dialogContainer.addView(message) - - val buttonsRow = - LinearLayout(parentCtx).apply { - orientation = LinearLayout.HORIZONTAL - val lp = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - layoutParams = lp - } - - val reloadNowBtn = - Button(parentCtx).apply { - text = "Reload Now" - setTextColor(0xFF000000.toInt()) - setBackgroundColor(0xFFBB86FC.toInt()) - val wlp = - LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ) - wlp.marginEnd = 8 - layoutParams = wlp - setOnClickListener { - try { - reloadApp() - } catch (_: Throwable) {} - } - } - val laterBtn = - Button(parentCtx).apply { - text = "Later" - setTextColor(0xFFFFFFFF.toInt()) - val wlp = - LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ) - layoutParams = wlp - } - - buttonsRow.addView( - reloadNowBtn, - LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ) - .apply { marginEnd = 8 } - ) - buttonsRow.addView( - laterBtn, - LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ) - ) - - dialogContainer.addView(buttonsRow) - - val dlg = AlertDialog.Builder(parentCtx).setView(dialogContainer).create() - laterBtn.setOnClickListener { dlg.dismiss() } - dlg.show() - } catch (t: Throwable) { - Log.e("Failed to show reload confirm modal: ${t.message}", t) - XposedBridge.log( - "ShiggyXposed: Reload confirm modal failed: ${android.util.Log.getStackTraceString(t)}" - ) - try { - AlertDialog.Builder(parentCtx) - .setTitle("Reload now?") - .setMessage( - if (turningOn) - "All add-ons will be temporarily disabled upon reload." - else "All add-ons will load normally." - ) - .setPositiveButton("Reload Now") { _, _ -> reloadApp() } - .setNegativeButton("Later", null) - .show() - } catch (_: Throwable) {} - } - } - - // Safe mode toggle removed; no-op. - - // URL label + input - val lblUrl = - TextView(themed).apply { - text = "Custom bundle URL" - setTextColor(0xFFFFFFFF.toInt()) - textSize = 13f - val lp = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - lp.topMargin = (themed.resources.displayMetrics.density * 8).toInt() - layoutParams = lp - } - val urlInput = - EditText(themed).apply { - hint = "https://example.com/bundle.js" - setTextColor(0xFFFFFFFF.toInt()) - setHintTextColor(0xFFBDBDBD.toInt()) - setBackgroundColor(0xFF1E1E1E.toInt()) - val lp = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - lp.topMargin = (themed.resources.displayMetrics.density * 6).toInt() - layoutParams = lp - visibility = View.GONE - } - container.addView(lblUrl) - container.addView(urlInput) - - // Buttons row - weighted buttons for consistent spacing - val buttonsRow = - LinearLayout(themed).apply { - orientation = LinearLayout.HORIZONTAL - val lp = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - lp.topMargin = (themed.resources.displayMetrics.density * 12).toInt() - layoutParams = lp - } - - fun createButton(text: String): Button { - return Button(themed).apply { - this.text = text - setAllCaps(false) - setTextColor(0xFFFFFFFF.toInt()) - val bLp = - LinearLayout.LayoutParams( - 0, - LinearLayout.LayoutParams.WRAP_CONTENT, - 1f - ) - bLp.marginEnd = (themed.resources.displayMetrics.density * 8).toInt() - layoutParams = bLp - minHeight = (themed.resources.displayMetrics.density * 36).toInt() - setBackgroundColor(0x00000000) - } - } - - val deleteBtn = createButton("Delete bundle") - val closeBtn = createButton("Close") - val saveBtn = - createButton("Save & Reload").apply { - setBackgroundColor(0xFFBB86FC.toInt()) - setTextColor(0xFF000000.toInt()) - } - - // Add Delete and Close to the top row (equal width) - buttonsRow.addView(deleteBtn) - buttonsRow.addView(closeBtn) - container.addView(buttonsRow) - - // Save button in its own row (full-width look) - val saveRow = - LinearLayout(themed).apply { - orientation = LinearLayout.HORIZONTAL - val lp = - LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ) - lp.topMargin = (themed.resources.displayMetrics.density * 8).toInt() - layoutParams = lp - } - - val saveLp = - LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - saveBtn.layoutParams = saveLp - saveBtn.minHeight = (themed.resources.displayMetrics.density * 40).toInt() - saveRow.addView(saveBtn) - container.addView(saveRow) - - // Initialize UI state - customSwitch.isChecked = customEnabled - urlInput.setText(customUrl) - urlInput.visibility = if (customEnabled) View.VISIBLE else View.GONE - - // Toggle URL input visibility when switching custom URL on/off - customSwitch.setOnCheckedChangeListener { _, checked -> - urlInput.visibility = if (checked) View.VISIBLE else View.GONE - } - - // Save button: only persist custom URL config and then reload - saveBtn.setOnClickListener { - try { - val enabled = customSwitch.isChecked - val url = urlInput.text?.toString() ?: "" - val escapedUrl = url.replace("\\", "\\\\").replace("\"", "\\\"") - val json = - "{\"customLoadUrl\":{\"enabled\":$enabled,\"url\":\"$escapedUrl\"}}" - runCatching { configFile.writeText(json) }.onFailure { t -> - Log.e("Failed to write loader.json: ${t.message}", t) - XposedBridge.log( - "ShiggyXposed: write loader.json failed: ${android.util.Log.getStackTraceString(t)}" - ) - } - } catch (t: Throwable) { - Log.e("Failed to save loader.json: ${t.message}", t) - } - reloadApp() - } - - // Delete bundle - deleteBtn.setOnClickListener { - try { - val bundleFile = - File( - packageParam.appInfo.dataDir, - "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}" - ) - if (bundleFile.exists()) bundleFile.delete() - } catch (t: Throwable) { - Log.e("Failed to delete bundle: ${t.message}", t) - } - reloadApp() - } - - // Close button dismisses the created dialog - val dlg = AlertDialog.Builder(themed).setView(container).create() - dlg.show() - closeBtn.setOnClickListener { dlg.dismiss() } - } catch (e: Throwable) { - // Log and fallback to old simple dialog - Log.e("Dialog failed, falling back: ${e.message}", e) - XposedBridge.log( - "ShiggyXposed: Dialog failed: ${android.util.Log.getStackTraceString(e)}" - ) - - try { - AlertDialog.Builder(context) - .setTitle("ShiggyXposed Recovery Options") - .setItems(arrayOf("Reload", "Delete bundle.js")) { _, which -> - when (which) { - 0 -> reloadApp() - 1 -> { - val bundleFile = - File( - packageParam.appInfo.dataDir, - "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}" - ) - if (bundleFile.exists()) bundleFile.delete() - reloadApp() - } - } - } - .show() - } catch (t: Throwable) { - Log.e("Fallback dialog also failed: ${t.message}", t) - XposedBridge.log( - "ShiggyXposed: Fallback dialog failed: ${android.util.Log.getStackTraceString(t)}" - ) - } - } - } - } -} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt deleted file mode 100644 index 17a6de5..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt +++ /dev/null @@ -1,108 +0,0 @@ -package ShiggyXposed.xposed.modules - -import ShiggyXposed.xposed.BuildConfig -import ShiggyXposed.xposed.Constants -import ShiggyXposed.xposed.Module -import ShiggyXposed.xposed.Utils.Companion.JSON -import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR -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 java.io.File -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put - -// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule -// if needed -class PayloadGlobalModule(private val modules: List) : Module() { - private companion object { - const val GLOBAL_NAME = "__PYON_LOADER__" - } - - private fun getPayloadString(): String = - JSON.encodeToString( - buildJsonObject { - put("loaderName", Constants.LOADER_NAME) - put("loaderVersion", BuildConfig.VERSION_NAME) - @Suppress("DEPRECATION") for (module in modules) module.buildPayload(this) - } - ) - - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = - with(packageParam) { - val catalystInstance = - classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl") - val reactInstance = - classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1") - - val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit = - { param, key, json -> - runCatching { - // Attempt old CatalystInstanceImpl method - catalystInstance!! - .method( - "setGlobalVariable", - String::class.java, - String::class.java - ) - .invoke(param.thisObject, key, json) - } - .onFailure { - // Failed (likely because it's a stub), use a workaround - val preloadsDir = - File( - "${appInfo.dataDir}/${Constants.FILES_DIR}", - PRELOADS_DIR - ) - File(preloadsDir, "rv_globals_$key.js").apply { - writeText("this[${JSON.encodeToString(key)}]=$json") - - XposedBridge.invokeOriginalMethod( - reactInstance!!.method( - "loadScriptFromFile", - String::class.java, - String::class.java, - Boolean::class.javaPrimitiveType - ), - param.thisObject, - arrayOf( - absolutePath, - absolutePath, - param.args[2] - ) - ) - - delete() - } - } - } - - val hook = - MethodHookBuilder().run { - before { setGlobalVariable(param, GLOBAL_NAME, getPayloadString()) } - - build() - } - - listOf(catalystInstance, reactInstance).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/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt deleted file mode 100644 index 9411655..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt +++ /dev/null @@ -1,148 +0,0 @@ -package ShiggyXposed.xposed.modules - -import ShiggyXposed.xposed.Constants -import ShiggyXposed.xposed.Module -import ShiggyXposed.xposed.Utils.Companion.JSON -import ShiggyXposed.xposed.Utils.Log -import android.app.Activity -import android.util.AtomicFile -import androidx.core.util.writeBytes -import de.robv.android.xposed.callbacks.XC_LoadPackage -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 java.io.File -import kotlinx.coroutines.* -import kotlinx.serialization.Serializable - -@Serializable data class CustomLoadUrl(val enabled: Boolean = false, val url: String = "") - -@Serializable data class LoaderConfig(val customLoadUrl: CustomLoadUrl = CustomLoadUrl()) - -/** - * Module that updates the JS bundle by downloading it from a remote URL. - * - * Shows dialogs when failed allowing retry. - */ -class UpdaterModule : Module() { - private lateinit var config: LoaderConfig - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var error: Throwable? = null - - private lateinit var cacheDir: File - private lateinit var bundle: File - private lateinit var etag: File - - companion object { - var job: Job? = null - - private const val TIMEOUT_CACHED = 5000L - private const val TIMEOUT = 50000L - private const val ETAG_FILE = "etag.txt" - private const val CONFIG_FILE = "loader.json" - - private const val DEFAULT_BUNDLE_URL = - "https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/shiggycord.js" - } - - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = - with(packageParam) { - cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() } - val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() } - - bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE) - etag = File(cacheDir, ETAG_FILE) - - val configFile = File(filesDir, CONFIG_FILE) - - config = - runCatching { - if (configFile.exists()) { - JSON.decodeFromString(configFile.readText()) - } else LoaderConfig() - } - .getOrDefault(LoaderConfig()) - - downloadScript() - } - - private fun downloadScript(activity: Activity? = null) { - job = - scope.launch { - try { - HttpClient(CIO) { - expectSuccess = false - install(UserAgent) { agent = Constants.USER_AGENT } - install(HttpRedirect) {} - } - .use { client -> - val url = - config.customLoadUrl.takeIf { it.enabled }?.url - ?: DEFAULT_BUNDLE_URL - Log.i("Fetching JS bundle from: $url") - - val response: HttpResponse = - client.get(url) { - headers { - if (etag.exists() && bundle.exists()) { - append( - HttpHeaders.IfNoneMatch, - etag.readText() - ) - } - } - - // Retries don't need timeout - if (activity != null) { - var timeoutMs = TIMEOUT_CACHED - if (!bundle.exists()) { - timeoutMs = TIMEOUT - } - timeout { requestTimeoutMillis = timeoutMs } - } - } - - 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) {} - } - } - HttpStatusCode.NotModified -> { - Log.i("Server responded with 304, no changes") - } - else -> { - throw ResponseException( - response, - "Received status: ${response.status}" - ) - } - } - } - } catch (e: Throwable) { - Log.e("Failed to download script", e) - error = e - } - } - } - - override fun onActivity(activity: Activity) { - error ?: return - error = null - downloadScript(activity) - } -} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt deleted file mode 100644 index 1b62ebb..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt +++ /dev/null @@ -1,271 +0,0 @@ -package ShiggyXposed.xposed.modules.appearance - -import ShiggyXposed.xposed.Constants -import ShiggyXposed.xposed.Module -import ShiggyXposed.xposed.Utils.Companion.JSON -import ShiggyXposed.xposed.Utils.Log -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 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 java.io.File -import java.io.IOException -import kotlinx.coroutines.* -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObjectBuilder -import kotlinx.serialization.json.put - -@Serializable -data class FontDefinition( - val name: String? = null, - val description: String? = null, - val spec: Int? = null, - val main: Map, -) - -class FontsModule : Module() { - private companion object { - val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") - val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") - const val FONTS_ASSET_PATH = "fonts/" - } - - private lateinit var fontsDir: File - private lateinit var fontsDownloadsDir: File - 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/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt deleted file mode 100644 index 74bcc4b..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt +++ /dev/null @@ -1,52 +0,0 @@ -package ShiggyXposed.xposed.modules.bridge - -import ShiggyXposed.xposed.Module -import android.app.AlertDialog -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Build -import android.widget.Toast - -class AdditionalBridgeMethodsModule : Module() { - override fun onContext(context: Context) = - with(context) { - BridgeModule.registerMethod("revenge.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("ShiggyXposed Error") - .setMessage( - """ - ShiggyXposed: $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 - } - } -} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt deleted file mode 100644 index 59f4763..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt +++ /dev/null @@ -1,208 +0,0 @@ -package ShiggyXposed.xposed.modules.bridge - -import ShiggyXposed.xposed.BuildConfig -import ShiggyXposed.xposed.Constants -import ShiggyXposed.xposed.Module -import ShiggyXposed.xposed.Utils.Log -import de.robv.android.xposed.callbacks.XC_LoadPackage -import java.io.File -import java.lang.reflect.Method - -/** - * See for possible return types: - * https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19 - * - * You may return a [Unit] and the resulting value will be `null`. - */ -typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any? - -typealias BridgeMethodArgs = ArrayList - -/** - * A module that exposes a bridge for calling methods from JavaScript. - * - * To call a method, pass an object with the following structure to a hooked method: - * ```js - * { - * revenge: { - * method: "method.name", - * args: [arg1, arg2, ...] - * } - * } - * ``` - * - * For methods that accept additional arguments, only the first object argument is used for the - * bridge call. - * - * The result will be an object with either a `result` or `error` key: - * ```js - * { - * result: ... // The return value of the method - * } - * ``` - * or - * ```js - * { - * error: "Error message" - * } - * ``` - */ -class BridgeModule : Module() { - private lateinit var readableMapGetString: Method - private lateinit var readableMapToHashMapMethod: Method - private lateinit var argumentsMakeNative: Method - - companion object { - private const val CALL_DATA_KEY = "revenge" - private const val METHOD_NAME_KEY = "method" - private const val METHOD_ARGS_KEY = "args" - - private fun File.openFileGuarded() { - if (!this.exists()) throw Error("Path does not exist: $path") - if (!this.isFile) throw Error("Path is not a file: $path") - } - - private val methods: MutableMap = - mutableMapOf( - "revenge.info" to - { - mapOf( - "name" to Constants.LOADER_NAME, - "version" to BuildConfig.VERSION_CODE - ) - }, - "revenge.test" to - { - mapOf( - "string" to "string", - "number" to 7256, - "array" to listOf("testing", 527737, listOf(true)), - "object" to mapOf("nested" to true), - "boolean" to false, - "args" to it, - ) - }, - "revenge.fs.delete" to - { - val (path) = it - File(path as String).run { - if (this.isDirectory) this.deleteRecursively() - else this.delete() - } - }, - "revenge.fs.exists" to - { - val (path) = it - File(path as String).exists() - }, - "revenge.fs.read" to - { it -> - val (path) = it - val file = File(path as String).apply { openFileGuarded() } - - file.bufferedReader().use { it.readText() } - }, - "revenge.fs.write" to - { it -> - val (path, contents) = it - val file = File(path as String).apply { openFileGuarded() } - - file.writeText(contents as String) - } - ) - - /** - * Registers a bridge method that can be called from JavaScript. - * - * If the method is already registered, it will be overwritten. - * - * @param name The name of the method to register. - * @param callback The callback to invoke when the method is called. - */ - fun registerMethod(name: String, callback: BridgeMethodCallback) { - if (methods.containsKey(name)) - Log.w("Bridge method already exists and will be overridden: $name") - methods[name] = callback - } - } - - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { - with(packageParam) { - val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments") - val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap") - val promise = classLoader.loadClass("com.facebook.react.bridge.Promise") - - val promiseResolve = promise.method("resolve", Object::class.java) - argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java) - readableMapGetString = readableMap.method("getString", String::class.java) - readableMapToHashMapMethod = readableMap.method("toHashMap") - - classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager").hookMethod( - "getBBox", - Double::class.javaObjectType, - readableMap - ) { - before { - callBridgeMethod(readableMapToHashMapConverter(args[1]!!))?.let { - result = it.toNativeObject() - } - } - } - - classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule").hookMethod( - "readAsDataURL", - readableMap, - promise - ) { - before { - // Avoid shadowing the outer `readableMap` and `promise` variables - val (readableMapArg, promiseArg) = args - callBridgeMethod(readableMapToHashMapConverter(readableMapArg!!))?.let { - promiseResolve.invoke(promiseArg, it.toNativeObject()) - result = null - } - } - } - } - } - - private fun Any?.toNativeObject(): Any? = - argumentsMakeNative.invoke( - null, - when (this) { - Unit -> null - else -> this - } - ) - - private fun readableMapToHashMapConverter(map: Any): HashMap { - @Suppress("UNCHECKED_CAST") - return readableMapToHashMapMethod.invoke(map) as HashMap - } - - private fun callBridgeMethod(hashMap: HashMap): Map? { - try { - val pair = getBridgeCallData(hashMap) ?: return null - val (method, args) = pair - val ret = method(args).toNativeObject() - return mapOf("result" to ret) - } catch (e: Throwable) { - return mapOf("error" to e.toString()) - } - } - - private fun getBridgeCallData( - hashMap: HashMap - ): Pair? { - @Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap? - data ?: return null - - @Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String - val method = methods[name] - method ?: throw Error("Method not registered: $name") - - @Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs - - return Pair(method, args) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 09117af..bb6ec98 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,2 @@ -rootProject.name = "KettuXposed" +rootProject.name = "ShiggyXposed" include(":app")