Compare commits

..

34 commits

Author SHA1 Message Date
Rosie Val
2864920cf2 fix: remove broken perf patches that hang kv storage, fix resource package mappings, and fix fonts hook 2026-09-25 17:43:43 -04:00
Rosie Val
3e47076257 feat(compat): add GmsCompatModule for microG and de-Googled device support 2026-09-25 17:18:04 -04:00
Rosie Val
d91fdb13f9 fix(module): add missing Log import in Module.kt 2026-09-25 17:10:41 -04:00
Rosie Val
47075c116e fix: resolve StackOverflowError in Module hookMethod, fix resource identifier mapping, and auto-publish release APKs in CI 2026-09-25 17:04:50 -04:00
Rosie Val
96e2755509 feat(scope): expand xposed_scope to dev.shiggy.cord and Discord variants 2026-09-25 16:41:48 -04:00
Rosie Val
38f45a7e0b fix(hook): prevent startup deadlock, fix target package/activity filters, restore bundle asset and shake detector 2026-09-25 16:34:09 -04:00
Rosie Val
43f024969d fix(ci): fix compilation errors from package rename, restore missing imports and helpers, fix 404 bundle URL 2026-09-25 16:14:38 -04:00
OTKUSteyler
109f7d5775
Update SysColorsModule.kt 2026-09-25 19:49:01 +02:00
OTKUSteyler
3146978ff2
Update PayloadGlobalModule.kt 2026-09-25 19:41:40 +02:00
OTKUSteyler
fe77d0624b
Update Main.kt 2026-09-25 19:32:03 +02:00
OTKUSteyler
748bf1820c
Update ThemesModule.kt 2026-09-25 19:23:07 +02:00
OTKUSteyler
488f59c84a
Update FontsModule.kt 2026-09-25 19:22:28 +02:00
OTKUSteyler
afb552f3ce
Update build.gradle.kts 2026-09-25 19:21:17 +02:00
OTKUSteyler
7811c97fd7
Add files via upload 2026-09-25 19:20:19 +02:00
OTKUSteyler
dff5dd3f95
Add files via upload 2026-09-25 19:19:20 +02:00
OTKUSteyler
7cfc42ce8c
Add files via upload 2026-09-25 19:18:33 +02:00
OTKUSteyler
9e2fa39ed2
Update CacheModule.kt 2026-09-25 19:18:00 +02:00
OTKUSteyler
122a19bfe0
Update FixResourcesModule.kt 2026-09-25 19:17:25 +02:00
OTKUSteyler
404c0c63ff
Update HookScriptLoaderModule.kt 2026-09-25 19:16:59 +02:00
OTKUSteyler
aab7f6ad27
Update PerfPatchesModule.kt 2026-09-25 19:16:02 +02:00
OTKUSteyler
1ac17ff0dd
Update PluginsModule.kt 2026-09-25 19:15:26 +02:00
OTKUSteyler
9afa0c264a
Update UpdaterModule.kt 2026-09-25 19:14:49 +02:00
OTKUSteyler
852848f0dd
Update Constants.kt 2026-09-25 19:13:14 +02:00
OTKUSteyler
d83c3c8ea5
Update Module.kt 2026-09-25 19:12:41 +02:00
OTKUSteyler
fc28e79bb4
Update Utils.kt 2026-09-25 19:12:20 +02:00
OTKUSteyler
eece858568
Update AndroidManifest.xml 2026-09-25 19:11:13 +02:00
OTKUSteyler
a045a5edad
Update strings.xml 2026-09-25 19:10:31 +02:00
OTKUSteyler
7cc04647e2
Update xposed_init 2026-09-25 19:10:12 +02:00
OTKUSteyler
fb8a4ac305
Update README.md 2026-09-25 18:36:42 +02:00
OTKUSteyler
3e3550f5d3
Update README.md 2026-09-25 18:35:21 +02:00
OTKUSteyler
f1fb057aa0
Update README.md 2026-09-25 18:19:48 +02:00
kmmiio99o
b84bd3c346
chore: bump to 1.3.6 2026-09-25 16:58:28 +02:00
kmmiio99o
0b39a29e8e
fix: prevent module load failures from hiding fonts and themes pages 2026-09-25 16:58:08 +02:00
kmmiio99o
7d15a63b4c
feat(build): disable manifying 2026-09-25 16:42:11 +02:00
23 changed files with 757 additions and 1547 deletions

View file

@ -1,6 +1,13 @@
name: Build APK name: Build APK
on: [push] on:
push:
branches: [ "master" ]
tags: [ "v*", "V*" ]
workflow_dispatch:
permissions:
contents: write
jobs: jobs:
build: build:
@ -65,3 +72,23 @@ jobs:
with: with:
name: app-release name: app-release
path: /tmp/ShiggyXposedbuild/apks/app-release.apk path: /tmp/ShiggyXposedbuild/apks/app-release.apk
- name: Publish APKs to GitHub Release
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/master'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
else
TAG_NAME=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "")
fi
if [ -n "$TAG_NAME" ]; then
echo "Uploading APKs to release $TAG_NAME..."
gh release upload "$TAG_NAME" \
/tmp/ShiggyXposedbuild/apks/app-release.apk \
/tmp/ShiggyXposedbuild/apks/app-debug.apk \
--clobber
else
echo "No release tag found to upload assets to."
fi

2
.idea/.name generated
View file

@ -1 +1 @@
GoonXposed ShiggyXposed

203
README.md
View file

@ -1,40 +1,193 @@
# GoonXposed # GoonXposed
GoonXposed is a root Xposed module that injects the GoonCord Discord mobile client modifications into the official Discord application. GoonXposed is an Xposed module (Kotlin) that injects the GoonCord client modifications into the official Discord Android application. It provides an in-app recovery/dev menu (LogBox) that lets you manage bundle loading, themes, and other developer helpers.
## Info This README explains prerequisites, installation, usage, troubleshooting, and credits. If you want help, open an issue on the repository or join the project's support server (links below).
* Injects GoonCord client into the Discord mobile app.
* Built in Kotlin. ---
* Material 3 like Shake menu with some discord popup accents
## Table of Contents
- [What is this](#what-is-this)
- [Features](#features)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Using the Module](#using-the-module)
- [Recovery / Options / Themes menu](#recovery--options--themes-menu)
- [Bundle injection / Custom bundle URL](#bundle-injection--custom-bundle-url)
- [Development / Building](#development--building)
- [Troubleshooting & FAQ](#troubleshooting--faq)
- [Contributing](#contributing)
- [Credits](#credits)
- [License](#license)
- [Contact & Links](#contact--links)
---
## What is this
GoonXposed injects a patched GoonCord bundle into the official Discord application by using the Xposed framework. It's intended for users who run a rooted Android device and want to apply client-side modifications to Discord.
---
## Features
- Inject a custom GoonCord bundle into Discord.
- Toggle bundle injection on/off (marker-based).
- Load a custom bundle URL and toggle it on/off.
- Recovery/LogBox action sheet for reload, safe-mode, refetch/reset, and options.
- Options to change the LogBox menu appearance (System / Light / Dark) and a set of menu color "flavors".
- In-place UI theming for the recovery/options menus.
- Helpers for refetching or backing up the currently loaded bundle.
---
## Prerequisites ## Prerequisites
To use this module, your device must meet the following requirements:
* **Root Access**: Provided by Magisk, KernelSU, KernelSUNext, or Sukisu Ultra. Before installing and using GoonXposed:
* **Xposed Framework**: Use JingMatrix Fork of [LSPosed](https://github.com/JingMatrix/LSPosed).
* **Target Application**: An official Discord application (stable, beta, or alpha version). - A rooted Android device. Supported root solutions you might use:
- Magisk, KernelSU, KernelSUNext, or Sukisu Ultra.
- Xposed framework installed. The project targets LSPosed or forks because of compatibility with modern Android and Discord:
- Recommended: JingMatrix Fork of [LSPosed](https://github.com/JingMatrix/LSPosed)
- The official Discord Android application (stable/beta/alpha) installed and included in the module scope.
---
## Installation ## Installation
1. Download the latest version of the module from the [Releases](https://github.com/OTKUSteyler/GoonXposed/releases) page.
2. Install the downloaded APK file on your device.
3. Open the LSPosed manager application.
4. Go to the **Modules** section and enable **GoonXposed**.
5. In the module's settings, set the scope to include the **Discord** application.
6. Reboot your device for the changes to take effect.
The project is built with Kotlin and inherits its foundational structure from KettuXposed. 1. Download the latest APK from the repository Releases:
- https://github.com/OTKUSteyler/GoonXposed/releases
2. Install the APK on your device (standard package install).
3. Open your LSPosed manager (or chosen Xposed manager).
4. Enable the GoonXposed module and ensure the module's scope includes the Discord app.
5. Reboot your device (or restart the system process) for changes to take effect.
# Troubleshooting ---
- Module not working: Ensure the module is enabled in LSPosed and that Discord is correctly selected in its scope. if still nothing, head to our [discord support server](https://discord.gg/nQykFF9Ud6) ## Using the Module
- Any other issues: Same server as above, listing them all is not worth it.
# Thanks & Credits Once installed and active, you can open the bundled Recovery/LogBox menu (depending on the hooked RN dev dialog) to access all tools.
This project was made possible thanks to the work of: Recovery → Options → Themes:
- Recovery (Action Sheet) includes:
- Safe Mode toggle (toggle persisted safe-mode setting + optional theme handling)
- Load Custom Bundle
- Reload App
- Options (opens the Options action sheet)
- Options includes:
- Themes (open appearance/flavor controls)
- Toggle bundle injection (rename/add marker file in cache to prevent injection)
- Refetch Bundle (moves or resets current bundle and triggers a reload)
- Clear Cache & Reset (clears cached bundle and resets loader config)
- Back (return to Recovery)
· The LSPosed Team for the framework. Themes:
· JingMatrix for their LSPosed fork. - Pick appearance: System / Light / Dark
· [cocobo1](https://codeberg.org/cocobo1) for adjusting the Xposed module for Kettu client mod. - Choose Menu Color Flavor: blue, green, mocha, vanilla, purple, amber, teal
· [Revenge Team](https://github.com/revenge-mod) for making a really good Xposed Module to base of - Theme & flavor settings persist under the app's files directory (`files/logbox/LOGBOX_SETTINGS` and `files/logbox/LOGBOX_THEMES`)
Bundle injection:
- When you disable injection, the module will create/rename a marker file in the cache directory so other components (loader) skip injection.
- Toggling injection is handled by `toggleBundleInjection(context)` and is persisted to `LOGBOX_SETTINGS`.
Custom bundle URL:
- The custom bundle loader configuration is stored in `files/pyoncord/loader.json`.
- You can enable/disable the custom URL and provide a remote URL (the module exposes a dialog for this).
---
## Development / Building
If you want to build the project locally:
- Prerequisites: JDK, Android SDK, and Gradle (the project uses the Gradle wrapper).
- From the repository root:
- Build debug APK:
```
./gradlew :app:assembleDebug
```
- Install to connected device (adb required):
```
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
- Run and test on a rooted device/emulator with Xposed/LSPosed installed and Discord in scope.
Notes:
- The project is written in Kotlin and uses Android UI primitives to construct the recovery/LogBox UI. The dev menu is created in-place to avoid modifying Discord views.
---
## Troubleshooting & FAQ
1. Module not working or not showing in Discord?
- Ensure GoonXposed is enabled in LSPosed and Discord is included in the module's scope.
- Reboot after enabling the module.
- Confirm you have the compatible LSPosed fork installed (JingMatrix recommended).
2. Recovery menu shows visual glitches when changing themes?
- The menu attempts to apply colors in-place. If you see transient visual artifacts, try closing and reopening the menu — the settings are persisted and will apply on reopen.
- If an area does not update, please report which dialog/control (and the device/Android version) so it can be targeted.
3. Bundle injection not toggling?
- Toggling injection manipulates files under the app's cache directory. If that fails, check file permissions or whether the cache path changed on your build of Discord.
- You can also manually inspect `data/data/<discord.package>/cache/...` to see `.disabled` marker files (depending on your device and build).
4. I accidentally broke Discord — how do I recover?
- Use the Recovery → Options → Clear Cache & Reset option to remove cached bundles and restore default loader settings, then reload Discord.
- If Discord fails to start, reboot the device to make sure changes are applied cleanly.
If you still have issues, open an issue on GitHub or join the support server (link below). Please include:
- Device model and Android version
- Discord app version
- A clear description of the problem and steps to reproduce
- Any relevant logs or screenshots (redact personal data)
---
## Contributing
Contributions are welcome. If you want to help:
- Open an issue to discuss a bug or feature.
- Send pull requests with focused, well-documented changes.
- When working on UI or theme changes, test on multiple screen sizes and Android versions if possible.
Guidelines:
- Keep commits small and descriptive.
- Include code comments for complex logic (hooks, file-system operations).
- Respect user privacy — do not add telemetry or data exfiltration.
---
## Credits
This project stands on the shoulders of many open-source projects and contributors:
- [LSPosed Team](https://github.com/LSPosed) — the Xposed framework implementation used to run modules.
- [JingMatrix](https://github.com/JingMatrix) — author of the LSPosed fork referenced by many users.
- [cocobo1](https://codeberg.org/cocobo1) — for adapting some features to Xposed which [Revenge Team](https://github.com/revenge-mod) didn't.
- [Revenge Team](https://github.com/revenge-mod) — provided earlier Xposed module work that informed parts of this project.
---
## License
Please refer to the `LICENSE` file in the repository root for the project license (if present). If no license file exists, the repository's default (copyright) status applies — check the repo settings or ask the maintainer for clarification.
---
## Contact & Links
- Releases: https://github.com/OTKUSteyler/GoonXposed/releases
- Repository: https://github.com/OTKUSteyler/GoonXposed
- Support server: https://discord.gg/nQykFF9Ud6
---
Thank you for using GoonXposed.
![OTKUSteyler](https://count.getloli.com/@OTKUSteyler?name=OTKUSteyler&theme=miku&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=1) ![OTKUSteyler](https://count.getloli.com/@OTKUSteyler?name=OTKUSteyler&theme=miku&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=1)

View file

@ -36,7 +36,7 @@ android {
} }
release { release {
isDebuggable = false isDebuggable = false
isMinifyEnabled = true isMinifyEnabled = false
val keystoreFile = file("../release.keystore") val keystoreFile = file("../release.keystore")
if (keystoreFile.exists()) { if (keystoreFile.exists()) {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
@ -68,6 +68,5 @@ dependencies {
implementation("io.ktor:ktor-client-core:3.3.1") implementation("io.ktor:ktor-client-core:3.3.1")
implementation("io.ktor:ktor-client-cio:3.3.1") implementation("io.ktor:ktor-client-cio:3.3.1")
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
implementation("dev.rikka.shizuku:api:13.1.5") implementation("dev.rushii:libunbound:1.0.0")
implementation("dev.rikka.shizuku:provider:13.1.5")
} }

View file

@ -16,6 +16,9 @@
android:value="Xposed module to inject GoonCord into discord" android:value="Xposed module to inject GoonCord into discord"
/> />
<meta-data android:name="xposedminversion" android:value="82" /> <meta-data android:name="xposedminversion" android:value="82" />
<meta-data android:name="xposedscope" android:value="com.discord" /> <meta-data
android:name="xposedscope"
android:resource="@array/xposed_scope"
/>
</application> </application>
</manifest> </manifest>

File diff suppressed because one or more lines are too long

View file

@ -16,6 +16,7 @@ import GoonXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule
import GoonXposed.xposed.modules.bridge.BridgeModule import GoonXposed.xposed.modules.bridge.BridgeModule
import GoonXposed.xposed.modules.no_track.BlockCrashReportingModule import GoonXposed.xposed.modules.no_track.BlockCrashReportingModule
import GoonXposed.xposed.modules.no_track.BlockDeepLinksTrackingModule import GoonXposed.xposed.modules.no_track.BlockDeepLinksTrackingModule
import GoonXposed.xposed.modules.LogBox.*
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
object HookStateHolder { object HookStateHolder {
@ -49,7 +50,8 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
PerfPatchesModule, PerfPatchesModule,
FontsModule, FontsModule,
ThemesModule, ThemesModule,
SysColorsModule SysColorsModule,
GmsCompatModule
) )
init { init {
@ -57,13 +59,41 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
} }
override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) { override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
for (module in modules) module.onInit(startupParam) dispatch("onInit") { module -> module.onInit(startupParam) }
}
private fun isTargetPackage(pkg: String): Boolean {
return pkg == Constants.TARGET_PACKAGE
|| pkg.startsWith("com.discord")
|| pkg == "dev.shiggy.cord"
|| pkg.contains("discord")
|| pkg.contains("gooncord")
} }
override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with(param) { override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with(param) {
if (!isTargetPackage(packageName)) return
if (processName.contains(":")) return
if (hooked) return if (hooked) return
val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY) val activityClassNames = listOf(
Constants.TARGET_ACTIVITY,
"$packageName.react_activities.ReactActivity",
"com.discord.react_activities.ReactActivity",
"com.facebook.react.ReactActivity",
"com.discord.main.MainActivity",
"com.discord.main.MainDefault"
)
var reactActivity: Class<*>? = null
for (className in activityClassNames) {
try {
reactActivity = classLoader.loadClass(className)
if (reactActivity != null) break
} catch (_: Throwable) {}
}
if (reactActivity == null) {
Log.w("Target activity not found by name, falling back to Activity base class")
}
ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) { ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) {
after { after {
@ -74,10 +104,18 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
} }
} }
reactActivity.hookMethod("onCreate", Bundle::class.java) { val targetActivityClass = reactActivity ?: Activity::class.java
targetActivityClass.hookMethod("onCreate", Bundle::class.java) {
after { after {
val act = thisObject as Activity val act = thisObject as Activity
Log.i("Received Activity") if (reactActivity == null &&
!act.javaClass.name.contains("ReactActivity", ignoreCase = true) &&
!act.javaClass.name.contains("MainActivity", ignoreCase = true) &&
!act.javaClass.name.contains("MainDefault", ignoreCase = true)
) {
return@after
}
Log.i("Received Activity: ${act.javaClass.name}")
if (!HookStateHolder.gotContext) { if (!HookStateHolder.gotContext) {
Log.w("Activity created before we got Context, process may have been recreated!") Log.w("Activity created before we got Context, process may have been recreated!")
@ -89,20 +127,53 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
} }
} }
targetActivityClass.hookMethod("onResume") {
after {
val act = thisObject as Activity
this@Main.onResume(act)
}
}
targetActivityClass.hookMethod("onPause") {
after {
val act = thisObject as Activity
this@Main.onPause(act)
}
}
this@Main.onLoad(param) this@Main.onLoad(param)
hooked = true hooked = true
} }
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
for (module in modules) module.onLoad(packageParam) dispatch("onLoad") { module -> module.onLoad(packageParam) }
} }
override fun onContext(context: Context) { override fun onContext(context: Context) {
for (module in modules) module.onContext(context) dispatch("onContext") { module -> module.onContext(context) }
} }
override fun onActivity(activity: Activity) { override fun onActivity(activity: Activity) {
for (module in modules) module.onActivity(activity) dispatch("onActivity") { module -> module.onActivity(activity) }
}
override fun onResume(activity: Activity) {
dispatch("onResume") { module -> module.onResume(activity) }
}
override fun onPause(activity: Activity) {
dispatch("onPause") { module -> module.onPause(activity) }
}
private fun dispatch(stage: String, block: (Module) -> Unit) {
for (module in modules) {
val name = module.javaClass.simpleName.ifEmpty { module.javaClass.name }
try {
block(module)
} catch (e: Throwable) {
Log.e("Module $name failed during $stage", e)
}
}
} }
} }

View file

@ -3,15 +3,24 @@ package GoonXposed.xposed
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import de.robv.android.xposed.IXposedHookZygoteInit import de.robv.android.xposed.IXposedHookZygoteInit
import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Utils.Log
import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonObjectBuilder
import java.io.File import java.io.File
import java.lang.reflect.Method import java.lang.reflect.Method
data class AppInfo(
val name: String,
val packageName: String,
val version: String,
val versionCode: Long,
)
/** /**
* Base class for every Xposed side module. * Base class for every Xposed side module.
* *
@ -19,23 +28,66 @@ import java.lang.reflect.Method
* registered [Module] instance. Modules may override any subset of the hooks below. * registered [Module] instance. Modules may override any subset of the hooks below.
*/ */
open class Module { open class Module {
/**
* Builds a JSON payload to be injected into the JavaScript context.
*/
@Deprecated("This will be removed in future versions. Payloads can be replaced via synchronous bridge methods.")
open fun buildPayload(builder: JsonObjectBuilder) {}
/**
* Called during Zygote initialization.
*/
open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {} open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {}
/**
* Called when a package is loaded.
*
* Hooks should typically be set up here.
*
* Bridge methods that do not require [Context] can also be registered here.
*/
open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {} open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {}
/**
* Called after [Context] has been attached to a [android.content.ContextWrapper]
* via [android.content.ContextWrapper.attachBaseContext].
*
* This may be called multiple times for different [Context]s, e.g. when the app is killed by the system.
*
* For bridge methods that require access to [Context], register them here.
*/
open fun onContext(context: Context) {} open fun onContext(context: Context) {}
/**
* Called after an [Activity.onCreate] method is executed.
*
* UI code that requires an [Activity] context can be placed here.
*
* Bridge methods must not be registered here, as it can be too late and cause crashes.
*
* If you need to register bridge methods that require an [Activity], do it in [onContext] and make it async.
*
* Once an [Activity] is available, execute the queued calls. Then from JS, call the method asynchronously.
*/
open fun onActivity(activity: Activity) {} open fun onActivity(activity: Activity) {}
@Deprecated("This method is deprecated in the parent class") open fun onResume(activity: Activity) {}
open fun buildPayload(builder: JsonObjectBuilder) {}
open fun onPause(activity: Activity) {}
} }
/** /**
* Runtime wrapper around an [XC_MethodHook.MethodHookParam] used as the receiver of every * Runtime wrapper around an [XC_MethodHook.MethodHookParam] used as the receiver of every
* `before`/`after` hook block, exposing [thisObject], [args], [result] and [param] directly. * `before`/`after` hook block, exposing [thisObject], [args], [result] and [param] directly.
*/ */
class HookScope(val param: XC_MethodHook.MethodHookParam) { class HookScope(
val param: XC_MethodHook.MethodHookParam,
private val proceed: ((XC_MethodHook.MethodHookParam) -> Unit)? = null
) {
fun proceed() {
proceed?.invoke(param)
}
val thisObject: Any? val thisObject: Any?
get() = param.thisObject get() = param.thisObject
@ -80,12 +132,12 @@ class MethodHookBuilder {
fun build(): XC_MethodHook = object : XC_MethodHook() { fun build(): XC_MethodHook = object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) { override fun beforeHookedMethod(param: MethodHookParam) {
val scope = HookScope(param) val scope = HookScope(param = param, proceed = { p -> super.beforeHookedMethod(p) })
beforeCallbacks.forEach { scope.run(it) } beforeCallbacks.forEach { scope.run(it) }
} }
override fun afterHookedMethod(param: MethodHookParam) { override fun afterHookedMethod(param: MethodHookParam) {
val scope = HookScope(param) val scope = HookScope(param = param, proceed = { p -> super.afterHookedMethod(p) })
afterCallbacks.forEach { scope.run(it) } afterCallbacks.forEach { scope.run(it) }
} }
} }
@ -104,28 +156,27 @@ fun ClassLoader.safeLoadClass(name: String): Class<*>? = try {
null null
} }
fun Class<*>.method(name: String, vararg parameterTypes: Class<*>): Method = fun Class<*>.method(name: String, vararg parameterTypes: Class<*>?): Method =
XposedHelpers.findMethodExact(this, name, *parameterTypes) XposedHelpers.findMethodExact(this, name, *parameterTypes)
fun Method.hook(block: MethodHookBuilder.() -> Unit) { fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook = XposedBridge.hookMethod(this, hook)
fun Method.hook(block: MethodHookBuilder.() -> Unit): XC_MethodHook.Unhook {
val builder = MethodHookBuilder() val builder = MethodHookBuilder()
builder.block() builder.block()
XposedBridge.hookMethod(this, builder.build()) return XposedBridge.hookMethod(this, builder.build())
}
fun Method.hook(hook: XC_MethodHook) {
XposedBridge.hookMethod(this, hook)
} }
fun Class<*>.hookMethod( fun Class<*>.hookMethod(
name: String, name: String,
vararg parameterTypes: Class<*>, vararg parameterTypes: Class<*>?,
block: MethodHookBuilder.() -> Unit block: MethodHookBuilder.() -> Unit
): Method { ): XC_MethodHook.Unhook? = runCatching {
val method = method(name, *parameterTypes) val method = method(name, *parameterTypes)
method.hook(block) method.hook(block)
return method }.onFailure {
} Log.w("Failed to hook method $name on ${this.name}: ${it.message}")
}.getOrNull()
fun File.asDir(): File { fun File.asDir(): File {
if (!exists() || !isDirectory) mkdirs() if (!exists() || !isDirectory) mkdirs()
@ -137,13 +188,6 @@ fun File.asFile(): File {
return this return this
} }
data class AppInfo(
val name: String,
val packageName: String,
val version: String,
val versionCode: Int
)
fun Context.getAppInfo(): AppInfo { fun Context.getAppInfo(): AppInfo {
val label = applicationInfo.loadLabel(packageManager).toString() val label = applicationInfo.loadLabel(packageManager).toString()
val packageInfo = try { val packageInfo = try {
@ -152,10 +196,15 @@ fun Context.getAppInfo(): AppInfo {
null null
} }
val versionCode = if (packageInfo != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) packageInfo.longVersionCode
else @Suppress("DEPRECATION") packageInfo.versionCode.toLong()
} else 0L
return AppInfo( return AppInfo(
name = label, name = label,
packageName = packageName, packageName = packageName,
version = packageInfo?.versionName ?: "", version = packageInfo?.versionName ?: "",
versionCode = packageInfo?.versionCode ?: 0 versionCode = versionCode
) )
} }

View file

@ -1,10 +1,9 @@
package GoonXposed.xposed package GoonXposed.xposed
import android.app.AlertDialog import android.app.AlertDialog
import android.app.Application import android.app.AndroidAppHelper
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import de.robv.android.xposed.XposedHelpers
import GoonXposed.xposed.modules.UpdaterModule import GoonXposed.xposed.modules.UpdaterModule
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
@ -15,16 +14,14 @@ class Utils {
val JSON = Json { ignoreUnknownKeys = true } val JSON = Json { ignoreUnknownKeys = true }
fun reloadApp() { fun reloadApp() {
val activityThread = XposedHelpers.findClass("android.app.ActivityThread", null) val application = AndroidAppHelper.currentApplication()
val application =
XposedHelpers.callStaticMethod(activityThread, "currentApplication") as? Application ?: return
val intent = application.packageManager.getLaunchIntentForPackage(application.packageName) val intent = application.packageManager.getLaunchIntentForPackage(application.packageName)
application.startActivity(Intent.makeRestartActivityTask(intent!!.component)) application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
exitProcess(0) exitProcess(0)
} }
fun showRecoveryAlert(context: Context) { fun showRecoveryAlert(context: Context) {
AlertDialog.Builder(context).setTitle("GoonCord Recovery Options") AlertDialog.Builder(context).setTitle("ShiggyCord Recovery Options")
.setItems(arrayOf("Reload", "Delete Script", "Reset Loader Config")) { _, which -> .setItems(arrayOf("Reload", "Delete Script", "Reset Loader Config")) { _, which ->
when (which) { when (which) {
0 -> { 0 -> {

View file

@ -1,6 +1,5 @@
package GoonXposed.xposed.modules package GoonXposed.xposed.modules
import GoonXposed.xposed.hookMethod
import android.content.res.Resources import android.content.res.Resources
import de.robv.android.xposed.callbacks.XC_LoadPackage import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE
@ -17,7 +16,13 @@ object FixResourcesModule : Module() {
"getIdentifier", String::class.java, String::class.java, String::class.java "getIdentifier", String::class.java, String::class.java, String::class.java
) { ) {
before { before {
if (args[2] == packageName) args[2] = TARGET_PACKAGE if (args[2] == packageName) {
args[2] = TARGET_PACKAGE
}
val name = args[0] as? String
if (name != null && name.startsWith("$packageName:")) {
args[0] = name.replaceFirst("$packageName:", "$TARGET_PACKAGE:")
}
} }
} }
} }

View file

@ -0,0 +1,69 @@
package GoonXposed.xposed.modules
import android.content.Context
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.hookMethod
import GoonXposed.xposed.safeLoadClass
/**
* Compatibility layer for environments without Google Play Services (microG, GrapheneOS, de-Googled ROMs).
*
* Mocks GoogleApiAvailability and GooglePlayServicesUtil availability checks so Discord does not
* hang, crash, or show "Google Play Services not available" prompts, and provides safe stubs for
* Google Advertising ID queries.
*/
object GmsCompatModule : Module() {
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
hookGoogleApiAvailability(classLoader)
hookAdvertisingId(classLoader)
}
private fun hookGoogleApiAvailability(classLoader: ClassLoader) {
listOf(
"com.google.android.gms.common.GoogleApiAvailability",
"com.google.android.gms.common.GoogleApiAvailabilityLight",
"com.google.android.gms.common.GooglePlayServicesUtil",
"com.google.android.gms.common.GooglePlayServicesUtilLight"
).forEach { className ->
val clazz = classLoader.safeLoadClass(className) ?: return@forEach
// ConnectionResult.SUCCESS = 0
clazz.hookMethod("isGooglePlayServicesAvailable", Context::class.java) {
before {
result = 0
}
}
clazz.hookMethod("isGooglePlayServicesAvailable", Context::class.java, Int::class.javaPrimitiveType) {
before {
result = 0
}
}
clazz.hookMethod("isUserResolvableError", Int::class.javaPrimitiveType) {
before {
result = false
}
}
}
}
private fun hookAdvertisingId(classLoader: ClassLoader) {
val adClientClass = classLoader.safeLoadClass("com.google.android.gms.ads.identifier.AdvertisingIdClient") ?: return
val adInfoClass = classLoader.safeLoadClass("com.google.android.gms.ads.identifier.AdvertisingIdClient\$Info")
if (adInfoClass != null) {
adClientClass.hookMethod("getAdvertisingIdInfo", Context::class.java) {
before {
try {
val constructor = adInfoClass.getDeclaredConstructor(String::class.java, Boolean::class.javaPrimitiveType)
constructor.isAccessible = true
result = constructor.newInstance("00000000-0000-0000-0000-000000000000", true)
Log.i("GmsCompat: returned mock AdvertisingIdInfo")
} catch (e: Throwable) {
Log.w("GmsCompat: failed to create mock AdvertisingIdInfo: ${e.message}")
}
}
}
}
}
}

View file

@ -19,6 +19,7 @@ import GoonXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.io.File import java.io.File
import java.lang.reflect.Method import java.lang.reflect.Method
@ -58,46 +59,53 @@ object HookScriptLoaderModule : Module() {
).mapNotNull { classLoader.safeLoadClass(it) }.forEach { hook(it) } ).mapNotNull { classLoader.safeLoadClass(it) }.forEach { hook(it) }
} }
private fun hook(instance: Class<*>) = runCatching { private fun hook(instance: Class<*>) {
val loadScriptFromAssets = instance.method( val loadScriptFromAssets = runCatching {
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType!! instance.method(
) "loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType
)
}.getOrNull()
val loadScriptFromFile = instance.method( val loadScriptFromFile = runCatching {
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType!! instance.method(
) "loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType
)
}.getOrNull()
loadScriptFromAssets.hook { loadScriptFromAssets?.hook {
before { before {
Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})") Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})")
runCustomScripts(loadScriptFromFile, loadScriptFromAssets) runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
} }
} }
loadScriptFromFile.hook { loadScriptFromFile?.hook {
before { before {
Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})") Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
runCustomScripts(loadScriptFromFile, loadScriptFromAssets) runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
} }
} }
}.onFailure {
Log.e("Failed to hook script loading methods in ${instance.name}:", it)
} }
private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) { private fun HookScope.runCustomScripts(loadScriptFromFile: Method?, loadScriptFromAssets: Method?) {
Log.i("Running custom scripts...") Log.i("Running custom scripts...")
runBlocking { runBlocking {
val ready = async { HookStateHolder.readyDeferred.join() } val isCustomUrl = UpdaterModule.isCustomUrlEnabled
if (!mainScript.exists()) { if (!mainScript.exists() || isCustomUrl) {
Log.i("Main script does not exist, downloading before load...") val reason = if (isCustomUrl) "Custom URL enabled" else "Main script does not exist"
val download = async { UpdaterModule.downloadScript().join() } Log.i("$reason, downloading before load...")
awaitAll(ready, download) try {
withTimeout(10000L) {
UpdaterModule.downloadScript(showUpdateDialog = false).join()
}
} catch (e: Throwable) {
Log.w("Bundle download did not complete in time", e)
}
} else { } else {
Log.i("Main script exists, updating in background...") Log.i("Main script exists, updating in background...")
UpdaterModule.downloadScript() UpdaterModule.downloadScript(showUpdateDialog = true)
ready.await()
} }
} }
@ -105,32 +113,48 @@ object HookScriptLoaderModule : Module() {
val runScriptFile = { file: File -> val runScriptFile = { file: File ->
Log.i("Loading script: ${file.absolutePath}") Log.i("Loading script: ${file.absolutePath}")
XposedBridge.invokeOriginalMethod( if (loadScriptFromFile != null) {
loadScriptFromFile, thisObject, arrayOf(file.absolutePath, file.absolutePath, loadSynchronously) XposedBridge.invokeOriginalMethod(
) loadScriptFromFile, thisObject, arrayOf(file.absolutePath, file.absolutePath, loadSynchronously)
)
}
Unit Unit
} }
try { try {
// If a disabled marker exists next to the cached bundle, treat this as a global
// disable for the entire script-loading step. In that case, we skip preloads,
// cached main script and the assets fallback so nothing is injected.
val disabledMarker = File(mainScript.parentFile, "${Constants.MAIN_SCRIPT_FILE}.disabled") val disabledMarker = File(mainScript.parentFile, "${Constants.MAIN_SCRIPT_FILE}.disabled")
if (disabledMarker.exists()) { if (disabledMarker.exists()) {
Log.i("Script loading disabled by marker; skipping preloads, cached bundle and fallback") Log.i("Script loading disabled by marker; skipping preloads, cached bundle and fallback")
} else { } else {
// Normal behaviour: run preloads then cached main script or fallback to bundled asset
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile) preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
if (mainScript.exists()) { if (mainScript.exists()) {
runScriptFile(mainScript) runScriptFile(mainScript)
} else { } else {
Log.i("Main script does not exist, falling back") Log.i("Main script does not exist, checking for fallback asset")
if (!::resources.isInitialized) resources = XModuleResources.createInstance(modulePath, null) if (!::resources.isInitialized) resources = XModuleResources.createInstance(modulePath, null)
XposedBridge.invokeOriginalMethod( var hasFallback = false
loadScriptFromAssets, try {
thisObject, resources.assets.open("Shiggy.bundle").use { hasFallback = true }
arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously) } catch (_: Throwable) {}
)
if (hasFallback && loadScriptFromAssets != null) {
Log.i("Loading fallback assets://Shiggy.bundle")
XposedBridge.invokeOriginalMethod(
loadScriptFromAssets,
thisObject,
arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously)
)
} else {
Log.w("No fallback bundle asset found; letting host app load default JS")
}
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {

View file

@ -134,7 +134,7 @@ object LogBoxActions {
val config = JSONObject(configFile.readText()) val config = JSONObject(configFile.readText())
val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
customLoadUrl.put("enabled", false) customLoadUrl.put("enabled", false)
customLoadUrl.put("url", "http://localhost:4040/gooncord.js") customLoadUrl.put("url", "http://localhost:4040/shiggycord.js")
config.put("customLoadUrl", customLoadUrl) config.put("customLoadUrl", customLoadUrl)
configFile.writeText(config.toString()) configFile.writeText(config.toString())
} }

View file

@ -3,6 +3,7 @@ package GoonXposed.xposed.modules.LogBox
import GoonXposed.xposed.Module import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.Utils.Companion.reloadApp import GoonXposed.xposed.Utils.Companion.reloadApp
import android.app.Activity
import android.content.Context import android.content.Context
import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XC_MethodReplacement import de.robv.android.xposed.XC_MethodReplacement
@ -10,33 +11,57 @@ import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage import de.robv.android.xposed.callbacks.XC_LoadPackage
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.lang.ref.WeakReference
object LogBoxModule : Module() { object LogBoxModule : Module() {
lateinit var packageParam: XC_LoadPackage.LoadPackageParam lateinit var packageParam: XC_LoadPackage.LoadPackageParam
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
var contextForMenu: Context? = null var contextForMenu: Context? = null
private var currentActivity: WeakReference<Activity>? = null
private val shakeDetector = ShakeDetector {
val act = currentActivity?.get() ?: return@ShakeDetector
act.runOnUiThread {
try {
Log.i("Shake detected! Opening Recovery Menu...")
LogBoxNavigation.showRecoveryMenu(act)
} catch (e: Throwable) {
Log.e("Failed to show recovery menu on shake: ${e.message}", e)
}
}
}
override fun onActivity(activity: Activity) {
currentActivity = WeakReference(activity)
contextForMenu = activity
}
override fun onResume(activity: Activity) {
currentActivity = WeakReference(activity)
contextForMenu = activity
try {
shakeDetector.start(activity)
Log.i("ShakeDetector started for activity: ${activity.javaClass.simpleName}")
} catch (e: Throwable) {
Log.e("Failed to start ShakeDetector: ${e.message}", e)
}
}
override fun onPause(activity: Activity) {
try {
shakeDetector.stop()
Log.i("ShakeDetector stopped")
} catch (e: Throwable) {
Log.e("Failed to stop ShakeDetector: ${e.message}", e)
}
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
this@LogBoxModule.packageParam = packageParam this@LogBoxModule.packageParam = packageParam
try { // Do not force getUseDeveloperSupport = true, as it causes React Native to loop
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") // attempting to connect to a Metro bundler on localhost:8081 and hang the app loading screen.
val getUseDeveloperSupportMethod = // Shake detector and recovery menu work independently without it.
dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
// NOTE: This used to call a custom `.hook { before { result = true } }` DSL that
// relied on a shared extensions file which broke (unresolved references).
// Rewritten using the standard XposedBridge/XC_MethodHook API, which is already
// used elsewhere in this file, to force this method to always return true.
XposedBridge.hookMethod(getUseDeveloperSupportMethod, object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
param.result = true
}
})
Log.e("Successfully hooked DCDReactNativeHost")
} catch (e: Exception) {
Log.e("Failed to hook DCDReactNativeHost: ${e.message}")
}
return@with return@with
} }
@ -126,7 +151,7 @@ object LogBoxModule : Module() {
Log.e("Failed to get context from DevSupport (non-fatal): ${e.message}") Log.e("Failed to get context from DevSupport (non-fatal): ${e.message}")
} }
val finalContext = activityContext ?: contextForMenu ?: context val finalContext = currentActivity?.get() ?: activityContext ?: contextForMenu ?: context
Log.e("Using context: $finalContext (type: ${finalContext.javaClass.name})") Log.e("Using context: $finalContext (type: ${finalContext.javaClass.name})")
LogBoxNavigation.showRecoveryMenu(finalContext) LogBoxNavigation.showRecoveryMenu(finalContext)

View file

@ -0,0 +1,56 @@
package GoonXposed.xposed.modules.LogBox
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlin.math.sqrt
class ShakeDetector(private val onShake: () -> Unit) : SensorEventListener {
private var sensorManager: SensorManager? = null
private var accelerometer: Sensor? = null
private var lastShakeTimestamp: Long = 0
fun start(context: Context): Boolean {
if (sensorManager != null) return true
sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
return accelerometer?.let {
sensorManager?.registerListener(this, it, SensorManager.SENSOR_DELAY_UI)
} ?: false
}
fun stop() {
sensorManager?.unregisterListener(this)
sensorManager = null
accelerometer = null
}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) return
val x = event.values[0] / SensorManager.GRAVITY_EARTH
val y = event.values[1] / SensorManager.GRAVITY_EARTH
val z = event.values[2] / SensorManager.GRAVITY_EARTH
// gForce will be close to 1 when there is no movement.
val gForce = sqrt((x * x + y * y + z * z).toDouble()).toFloat()
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
val now = System.currentTimeMillis()
// Ignore shake events too close to each other (1 second debounce)
if (now - lastShakeTimestamp > SHAKE_SLOP_TIME_MS) {
lastShakeTimestamp = now
onShake()
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
companion object {
private const val SHAKE_THRESHOLD_GRAVITY = 2.7f
private const val SHAKE_SLOP_TIME_MS = 1000L
}
}

View file

@ -9,6 +9,7 @@ import GoonXposed.xposed.Constants
import GoonXposed.xposed.MethodHookBuilder import GoonXposed.xposed.MethodHookBuilder
import GoonXposed.xposed.Module import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.hook import GoonXposed.xposed.hook
import GoonXposed.xposed.method import GoonXposed.xposed.method
import GoonXposed.xposed.safeLoadClass import GoonXposed.xposed.safeLoadClass
@ -27,7 +28,11 @@ class PayloadGlobalModule(private val modules: List<Module>) : Module() {
buildJsonObject { buildJsonObject {
put("loaderName", Constants.LOADER_NAME) put("loaderName", Constants.LOADER_NAME)
put("loaderVersion", BuildConfig.VERSION_NAME) put("loaderVersion", BuildConfig.VERSION_NAME)
@Suppress("DEPRECATION") for (module in modules) module.buildPayload(this) @Suppress("DEPRECATION") for (module in modules) runCatching {
module.buildPayload(this)
}.onFailure {
Log.e("Module ${module.javaClass.simpleName} failed while building payload", it)
}
}) })
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
@ -52,7 +57,7 @@ class PayloadGlobalModule(private val modules: List<Module>) : Module() {
"loadScriptFromFile", "loadScriptFromFile",
String::class.java, String::class.java,
String::class.java, String::class.java,
Boolean::class.javaPrimitiveType!! Boolean::class.javaPrimitiveType
), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2]) ), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2])
) )
@ -74,11 +79,11 @@ class PayloadGlobalModule(private val modules: List<Module>) : Module() {
private fun hook(instance: Class<*>, hook: XC_MethodHook) { private fun hook(instance: Class<*>, hook: XC_MethodHook) {
instance.method( instance.method(
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType!!, "loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType,
).hook(hook) ).hook(hook)
instance.method( instance.method(
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType!! "loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType
).hook(hook) ).hook(hook)
} }
} }

View file

@ -25,293 +25,20 @@ object PerfPatchesModule : Module() {
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
try { try {
// Build preloads path inside the app data files directory val filesDir = File(packageParam.appInfo.dataDir, Constants.FILES_DIR)
val filesDir = File(packageParam.appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() } val preloadsDir = File(filesDir, HookScriptLoaderModule.PRELOADS_DIR)
val preloadsDir = File(filesDir, HookScriptLoaderModule.PRELOADS_DIR).apply { mkdirs() }
val out = File(preloadsDir, FILE_NAME) val out = File(preloadsDir, FILE_NAME)
if (out.exists()) {
// Defensive JS runtime patch. Avoid template literals with ${} to prevent Kotlin interpolation. out.delete()
val js = """ Log.i("PerfPatchesModule: deleted legacy perf_patches.js")
(function(){
try {
if (!globalThis.__SHIGGY_PERF_PATCHES__) {
globalThis.__SHIGGY_PERF_PATCHES__ = { installedAt: Date.now(), enabled: true };
}
function safe(fn) {
return function() {
try { return fn.apply(this, arguments); }
catch (e) { try { console && console.warn && console.warn('shiggy patch error', e); } catch(_){} }
};
}
function memoize(fn, maxEntries) {
if (typeof fn !== 'function') return fn;
var cache = new Map();
maxEntries = Number(maxEntries) || 128;
return function() {
var key;
try { key = JSON.stringify(Array.prototype.slice.call(arguments)); }
catch (e) { key = String(arguments.length) + ':' + String(arguments[0]); }
if (cache.has(key)) return cache.get(key);
var res = fn.apply(this, arguments);
cache.set(key, res);
if (cache.size > maxEntries) {
var it = cache.keys().next();
if (!it.done) cache.delete(it.value);
}
return res;
};
}
function createBatched(fn, intervalMs) {
if (typeof fn !== 'function') return fn;
var buf = [];
var timer = null;
intervalMs = Number(intervalMs) || 2000;
function flush() {
var batch = buf.splice(0, buf.length);
if (!batch.length) return;
try { fn.call(null, batch); }
catch (e) {
// Fallback: call per event asynchronously
try {
batch.forEach(function(ev){
setTimeout(function(){ try { fn(ev); } catch (_) {} }, 0);
});
} catch (_) {}
}
}
return function(event) {
try {
buf.push(event);
if (!timer) timer = setTimeout(function(){ timer = null; flush(); }, intervalMs);
} catch (e) {}
};
}
function patchIfExists(path, wrapper) {
try {
var parts = path.split('.');
var obj = globalThis;
for (var i = 0; i < parts.length - 1; i++) {
obj = obj && obj[parts[i]];
}
if (!obj) return false;
var key = parts[parts.length - 1];
if (typeof obj[key] !== 'function') return false;
obj[key] = wrapper(obj[key]);
return true;
} catch (e) { return false; }
}
// Try memoizing a few well-known functions
['computeHappeningNowState', 'computeNowState'].forEach(function(name){
try { patchIfExists(name, function(orig){ return memoize(orig, 128); }); } catch(_) {}
});
// Try caching RowGenerator.prototype.generate by instance signature
try {
var RG = globalThis && globalThis.RowGenerator;
if (RG && RG.prototype && typeof RG.prototype.generate === 'function') {
(function(){
var orig = RG.prototype.generate;
RG.prototype.generate = function() {
try {
var sig;
if (arguments && arguments.length) {
var a0 = arguments[0];
if (Array.isArray(a0)) {
sig = 'arr:' + a0.length + ':' + a0.slice(0,8).map(function(x){ return x && x.id ? x.id : ''; }).join(',');
} else if (a0 && a0.id) {
sig = 'id:' + a0.id;
} else {
try { sig = JSON.stringify(a0).slice(0,200); } catch(e){ sig = String(a0); }
}
} else sig = 'noargs';
if (this.__shiggy_sig === sig && typeof this.__shiggy_cache !== 'undefined') {
return this.__shiggy_cache;
}
var res = orig.apply(this, arguments);
this.__shiggy_sig = sig;
this.__shiggy_cache = res;
return res;
} catch (e) { return orig.apply(this, arguments); }
};
})();
}
} catch (e) {}
// Batch analytics-like methods (best-effort)
['app_ui_viewed', 'logEvent', 'Analytics.log', 'Analytics.track', 'analytics.trackEvent'].forEach(function(name){
try {
var parts = name.split('.');
var obj = globalThis;
for (var i = 0; i < parts.length - 1; i++) obj = obj && obj[parts[i]];
if (!obj) return;
var key = parts[parts.length - 1];
if (typeof obj[key] === 'function') obj[key] = createBatched(obj[key], 2000);
} catch (e) {}
});
// Make console methods async to avoid blocking hot-paths during startup
(function(){
try {
if (typeof console !== 'undefined') {
['log','info','warn','error','debug','trace'].forEach(function(m){
try {
if (typeof console[m] === 'function') {
var orig = console[m];
console[m] = function() {
var args = Array.prototype.slice.call(arguments);
setTimeout(function(){ try { orig.apply(console, args); } catch(_){} }, 0);
};
}
} catch(e){}
});
}
} catch(e){}
})();
// Deduplicate and slightly delay repeated kv calls to reduce IO contention on startup
(function(){
try {
var pending = new Map();
// Lightweight stats for dedupe wrapper to help telemetry/debugging in-case we need to tune delays.
try {
globalThis.__SHIGGY_PERF_DEDUPE_STATS__ = globalThis.__SHIGGY_PERF_DEDUPE_STATS__ || {
methods: {},
totalRequests: 0,
totalHits: 0,
totalMisses: 0,
pending: 0,
lastUpdated: Date.now()
};
} catch(e) {}
function _incStats(name, key) {
try {
var s = globalThis.__SHIGGY_PERF_DEDUPE_STATS__;
if (!s) return;
s.lastUpdated = Date.now();
var m = s.methods[name] || (s.methods[name] = { requests: 0, hits: 0, misses: 0, pending: 0 });
if (key === 'requests') { m.requests++; s.totalRequests++; }
else if (key === 'hits') { m.hits++; s.totalHits++; }
else if (key === 'misses') { m.misses++; s.totalMisses++; }
else if (key === 'pending_inc') { m.pending++; s.pending++; }
else if (key === 'pending_dec') { m.pending = Math.max(0, m.pending - 1); s.pending = Math.max(0, s.pending - 1); }
} catch(e) {}
}
function wrapAsyncDedupe(kvObj, methodName, delayMs){
try {
var orig = kvObj[methodName];
if (typeof orig !== 'function') return;
kvObj[methodName] = function() {
var argsArr = Array.prototype.slice.call(arguments);
var key;
try { key = JSON.stringify([methodName, argsArr]); } catch(e){ key = methodName + ':' + String(argsArr[0]); }
_incStats(methodName, 'requests');
if (pending.has(key)) { _incStats(methodName, 'hits'); return pending.get(key); }
_incStats(methodName, 'misses');
var self = this;
var p = new Promise(function(resolve, reject){
setTimeout(function(){
try {
var res = orig.apply(self, argsArr);
if (res && typeof res.then === 'function') res.then(resolve, reject);
else resolve(res);
} catch (e) { reject(e); }
}, Number(delayMs) || 20);
});
pending.set(key, p);
_incStats(methodName, 'pending_inc');
var cleanup = function(){ pending.delete(key); _incStats(methodName, 'pending_dec'); };
p.then(cleanup, cleanup);
return p;
};
} catch(e){}
}
if (globalThis.kv) {
if (globalThis.kv.get_many) wrapAsyncDedupe(globalThis.kv, 'get_many', 20);
if (globalThis.kv.get_kv_entries) wrapAsyncDedupe(globalThis.kv, 'get_kv_entries', 20);
}
} catch(e){}
})();
// Defer heavy filesystem/storage calls slightly to prioritize UI rendering
(function(){
try {
function makeDeferredIfAsync(obj, name, delayMs) {
try {
var orig = obj[name];
if (typeof orig !== 'function') return;
var isAsync = orig.constructor && orig.constructor.name === 'AsyncFunction';
var src = String(orig);
if (!isAsync && src.indexOf('return new Promise') === -1 && src.indexOf('.then(') === -1) {
// Likely synchronous - skip deferral to avoid changing semantics
return;
}
obj[name] = function() {
var args = Array.prototype.slice.call(arguments);
var self = this;
return new Promise(function(resolve, reject){
setTimeout(function(){
try {
var res = orig.apply(self, args);
if (res && typeof res.then === 'function') res.then(resolve, reject);
else resolve(res);
} catch (e) { reject(e); }
}, Number(delayMs) || 100);
});
};
} catch(e){}
}
if (globalThis.db && typeof globalThis.db.fs_info === 'function') {
makeDeferredIfAsync(globalThis.db, 'fs_info', 100);
}
if (globalThis.Storage && typeof globalThis.Storage.refresh === 'function') {
makeDeferredIfAsync(globalThis.Storage, 'refresh', 50);
}
} catch(e){}
})();
// Expose lightweight API
try {
if (!globalThis.__SHIGGY_PERF_API__) {
globalThis.__SHIGGY_PERF_API__ = {
installedAt: Date.now(),
enabled: true,
uninstall: function() { try { globalThis.__SHIGGY_PERF_API__.enabled = false; } catch(e) {} }
};
}
} catch (e) {}
} catch (e) {
try { console && console.warn && console.warn('PerfPatches: error', e); } catch(_) {}
}
})();
""".trimIndent()
// Write file if absent or changed
try {
if (!out.exists() || out.readText() != js) {
out.writeText(js)
Log.i("PerfPatchesModule: wrote patch to " + out.absolutePath)
} else {
Log.i("PerfPatchesModule: perf patch already up-to-date")
}
} catch (e: Throwable) {
Log.e("PerfPatchesModule: failed writing patch", e)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e("PerfPatchesModule:onLoad failed", e) Log.e("PerfPatchesModule: cleanup failed", e)
} }
} }
@Deprecated("Overrides deprecated Module.buildPayload") @Deprecated("Overrides deprecated Module.buildPayload")
override fun buildPayload(builder: JsonObjectBuilder) { override fun buildPayload(builder: JsonObjectBuilder) {
builder.put("perfPatches", true) builder.put("perfPatches", false)
} }
} }

View file

@ -56,6 +56,9 @@ object UpdaterModule : Module() {
private const val ETAG_FILE = "etag.txt" private const val ETAG_FILE = "etag.txt"
private const val CONFIG_FILE = "loader.json" private const val CONFIG_FILE = "loader.json"
val isCustomUrlEnabled: Boolean
get() = if (::config.isInitialized) config.customLoadUrl.enabled else false
private const val DEFAULT_BUNDLE_URL = private const val DEFAULT_BUNDLE_URL =
"https://github.com/OTKUSteyler/GoonCord/releases/latest/download/gooncord.js" "https://github.com/OTKUSteyler/GoonCord/releases/latest/download/gooncord.js"
@ -77,7 +80,7 @@ object UpdaterModule : Module() {
}.getOrDefault(LoaderConfig()) }.getOrDefault(LoaderConfig())
} }
fun downloadScript(activity: Activity? = null): Job = scope.launch { fun downloadScript(activity: Activity? = null, showUpdateDialog: Boolean = true): Job = scope.launch {
try { try {
// Respect LogBox setting to disable bundle injections. // Respect LogBox setting to disable bundle injections.
// If the file "files/logbox/LOGBOX_SETTINGS" contains the flag bundleInjectionDisabled=true, // If the file "files/logbox/LOGBOX_SETTINGS" contains the flag bundleInjectionDisabled=true,
@ -129,7 +132,6 @@ object UpdaterModule : Module() {
Log.i("Bundle updated (${bytes.size} bytes)") Log.i("Bundle updated (${bytes.size} bytes)")
// This is a retry, so we show a dialog
if (activity != null) { if (activity != null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
AlertDialog.Builder(activity).setTitle("GoonCord Update Successful") AlertDialog.Builder(activity).setTitle("GoonCord Update Successful")
@ -139,6 +141,22 @@ object UpdaterModule : Module() {
dialog.dismiss() dialog.dismiss()
}.setCancelable(false).show() }.setCancelable(false).show()
} }
} else if (showUpdateDialog) {
val act = lastActivity?.get()
act?.runOnUiThread {
AlertDialog.Builder(act)
.setTitle("GoonCord Update Downloaded")
.setMessage("A reload is required for changes to take effect.")
.setPositiveButton("Reload") { dialog, _ ->
reloadApp()
dialog.dismiss()
}
.setNegativeButton("Later") { dialog, _ ->
dialog.dismiss()
}
.setCancelable(false)
.show()
}
} }
} }

View file

@ -1,5 +1,3 @@
@file:Suppress("DEPRECATION")
package GoonXposed.xposed.modules.appearance package GoonXposed.xposed.modules.appearance
import android.content.res.AssetManager import android.content.res.AssetManager
@ -15,6 +13,8 @@ import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.Utils.Log import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.asDir
import GoonXposed.xposed.asFile
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.engine.cio.* import io.ktor.client.engine.cio.*
@ -29,14 +29,6 @@ import kotlinx.serialization.json.put
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
// NOTE: These extensions used to be resolved from a shared Utils/Extensions file,
// but that reference broke (renamed/removed/relocated), causing "Unresolved reference"
// build failures. Defining them locally here removes the dependency on that file.
// asFile(): ensures the parent directory of a file exists.
// asDir(): ensures the directory itself exists.
private fun File.asFile(): File = apply { parentFile?.mkdirs() }
private fun File.asDir(): File = apply { mkdirs() }
@Serializable @Serializable
data class FontDefinition( data class FontDefinition(
val name: String? = null, val name: String? = null,
@ -50,6 +42,11 @@ object FontsModule : Module() {
private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
private const val FONTS_ASSET_PATH = "fonts/" private const val FONTS_ASSET_PATH = "fonts/"
private val FONT_MANAGER_CLASSES = arrayOf(
"com.facebook.react.views.text.ReactFontManager\$Companion",
"com.facebook.react.common.assets.ReactFontManager\$Companion",
)
private lateinit var fontsDir: File private lateinit var fontsDir: File
private lateinit var fontsDownloadsDir: File private lateinit var fontsDownloadsDir: File
private var fontsAbsPath: String? = null private var fontsAbsPath: String? = null
@ -61,21 +58,33 @@ object FontsModule : Module() {
} }
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
XposedHelpers.findAndHookMethod( val replacement = object : XC_MethodReplacement() {
"com.facebook.react.common.assets.ReactFontManager\$Companion", override fun replaceHookedMethod(param: MethodHookParam): Typeface? {
classLoader, val fontFamilyName: String = param.args[0].toString()
"createAssetTypeface", val style: Int = param.args[1] as Int
String::class.java, val assetManager: AssetManager = param.args[2] as AssetManager
Int::class.java, return createAssetTypeface(fontFamilyName, style, assetManager)
"android.content.res.AssetManager", }
object : XC_MethodReplacement() { }
override fun replaceHookedMethod(param: MethodHookParam): Typeface? {
val fontFamilyName: String = param.args[0].toString() // React Native moved ReactFontManager between releases, so try every known package.
val style: Int = param.args[1] as Int // Never let this throw: an uncaught error here aborts the whole module chain.
val assetManager: AssetManager = param.args[2] as AssetManager val hooked = FONT_MANAGER_CLASSES.any { className ->
return createAssetTypeface(fontFamilyName, style, assetManager) runCatching {
} XposedHelpers.findAndHookMethod(
}) className,
classLoader,
"createAssetTypeface",
String::class.java,
Int::class.javaPrimitiveType ?: java.lang.Integer.TYPE,
"android.content.res.AssetManager",
replacement
)
}.onFailure { Log.e("Failed to hook createAssetTypeface on $className", it) }
.isSuccess
}
if (!hooked) Log.e("Failed to hook createAssetTypeface on all known ReactFontManager classes")
val fontDefFile = File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply { asFile() } val fontDefFile = File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply { asFile() }
if (!fontDefFile.exists()) return@with if (!fontDefFile.exists()) return@with
@ -103,40 +112,28 @@ object FontsModule : Module() {
// These files should be downloaded by the JS side, but oh well // These files should be downloaded by the JS side, but oh well
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val client = HttpClient(CIO) { fontDef.main.keys.map { name ->
install(UserAgent) { agent = Constants.USER_AGENT } async {
install(HttpTimeout) { val url = fontDef.main.getValue(name)
requestTimeoutMillis = 15_000 try {
connectTimeoutMillis = 10_000 Log.i("Downloading $name from $url")
socketTimeoutMillis = 15_000 val file =
} File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}").apply { asFile() }
} if (file.exists()) return@async
try { val client = HttpClient(CIO) {
fontDef.main.keys.map { name -> install(UserAgent) { agent = Constants.USER_AGENT }
async {
val url = fontDef.main.getValue(name)
try {
val extension = FILE_EXTENSIONS.firstOrNull { url.substringBefore('?').endsWith(it) }
?: FILE_EXTENSIONS[0]
val file = File(fontsDir, "$name$extension").apply { asFile() }
if (file.exists()) return@async
Log.i("Downloading $name from $url")
val response: HttpResponse = client.get(url)
if (response.status == HttpStatusCode.OK) {
file.writeBytes(response.body())
} else {
Log.e("Failed to download fonts ($name from $url): HTTP ${response.status}")
}
} catch (e: Throwable) {
Log.e("Failed to download fonts ($name from $url)", e)
} }
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() }
} finally { }.awaitAll()
client.close()
}
} }
} }

View file

@ -1,10 +1,10 @@
package GoonXposed.xposed.modules.appearance package GoonXposed.xposed.modules.appearance
import android.R.color import android.R.color
import android.app.AndroidAppHelper
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.robv.android.xposed.XposedHelpers
import GoonXposed.xposed.Module import GoonXposed.xposed.Module
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@ -13,13 +13,8 @@ object SysColorsModule : Module() {
private lateinit var context: WeakReference<Context> private lateinit var context: WeakReference<Context>
private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@Deprecated("This method is deprecated in the parent class")
override fun buildPayload(builder: JsonObjectBuilder) { override fun buildPayload(builder: JsonObjectBuilder) {
context = WeakReference(runCatching { context = WeakReference(AndroidAppHelper.currentApplication())
XposedHelpers.callStaticMethod(
XposedHelpers.findClass("android.app.ActivityThread", null), "currentApplication"
) as Context
}.getOrNull())
val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2") val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2")
val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000) val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
@ -42,8 +37,7 @@ object SysColorsModule : Module() {
} }
private fun convertToColor(id: Int): String { private fun convertToColor(id: Int): String {
val ctx = context.get() val clr = if (isSupported()) ContextCompat.getColor(context.get()!!, id) else 0
val clr = if (isSupported() && ctx != null) ContextCompat.getColor(ctx, id) else 0
return String.format("#%06X", 0xFFFFFF and clr) return String.format("#%06X", 0xFFFFFF and clr)
} }
} }

View file

@ -86,18 +86,20 @@ object ThemesModule : Module() {
fun hookTheme() { fun hookTheme() {
try { try {
val themeManager = param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt") val themeManager = runCatching { param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt") }.getOrNull()
val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme") val darkTheme = runCatching { param.classLoader.loadClass("com.discord.theme.DarkTheme") }.getOrNull()
val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme") ?: runCatching { param.classLoader.loadClass("com.discord.theme.DarkerTheme") }.getOrNull()
val onyxTheme = runCatching { param.classLoader.loadClass("com.discord.theme.OnyxTheme") }.getOrNull()
val lightTheme = runCatching { param.classLoader.loadClass("com.discord.theme.LightTheme") }.getOrNull()
hookThemeInternal(themeManager, darkTheme, lightTheme) hookThemeInternal(themeManager, darkTheme, onyxTheme, lightTheme)
} catch (e: Throwable) { } catch (e: Throwable) {
GoonXposed.xposed.Utils.Log.e("Failed to hook theme: ${e.message}") GoonXposed.xposed.Utils.Log.e("Failed to hook theme: ${e.message}")
} }
} }
private fun hookThemeInternal( private fun hookThemeInternal(
themeManager: Class<*>, darkTheme: Class<*>, lightTheme: Class<*> themeManager: Class<*>?, darkTheme: Class<*>?, onyxTheme: Class<*>?, lightTheme: Class<*>?
) { ) {
val theme = this.theme ?: return val theme = this.theme ?: return
@ -111,26 +113,36 @@ object ThemesModule : Module() {
// TEXT_NORMAL -> getTextNormal // TEXT_NORMAL -> getTextNormal
val methodName = "get${key.fromScreamingSnakeToCamelCase()}" val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
value.forEachIndexed { index, v -> value.forEachIndexed { index, v ->
val colorInt = hexStringToColorInt(v)
when (index) { when (index) {
0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v)) 0 -> {
1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v)) if (darkTheme != null) hookThemeMethod(darkTheme, methodName, colorInt)
if (onyxTheme != null) hookThemeMethod(onyxTheme, methodName, colorInt)
}
1 -> {
if (lightTheme != null) hookThemeMethod(lightTheme, methodName, colorInt)
}
} }
} }
} }
// If there's any rawColors value, hook the color getter // If there's any rawColors value, hook the color getter
if (!theme.data.rawColors.isNullOrEmpty()) { if (!theme.data.rawColors.isNullOrEmpty() && themeManager != null) {
try { try {
val getColorCompat = themeManager.getDeclaredMethod( val getColorCompat = runCatching {
"getColorCompat", themeManager.getDeclaredMethod(
Resources::class.java, "getColorCompat",
Int::class.javaPrimitiveType, Resources::class.java,
Resources.Theme::class.java, Int::class.javaPrimitiveType,
) Resources.Theme::class.java,
)
}.getOrNull()
val getColorCompatLegacy = themeManager.getDeclaredMethod( val getColorCompatLegacy = runCatching {
"getColorCompat", Context::class.java, Int::class.javaPrimitiveType themeManager.getDeclaredMethod(
) "getColorCompat", Context::class.java, Int::class.javaPrimitiveType
)
}.getOrNull()
val patch = MethodHookBuilder().run { val patch = MethodHookBuilder().run {
before { before {
@ -147,8 +159,8 @@ object ThemesModule : Module() {
build() build()
} }
getColorCompat.hook(patch) getColorCompat?.hook(patch)
getColorCompatLegacy.hook(patch) getColorCompatLegacy?.hook(patch)
} catch (e: Throwable) { } catch (e: Throwable) {
GoonXposed.xposed.Utils.Log.e("Failed to hook getColorCompat: ${e.message}") GoonXposed.xposed.Utils.Log.e("Failed to hook getColorCompat: ${e.message}")
} }

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="xposed_scope">
<item>com.discord</item>
<item>com.discord.canary</item>
<item>com.discord.ptb</item>
<item>dev.shiggy.cord</item>
</string-array>
</resources>