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
on: [push]
on:
push:
branches: [ "master" ]
tags: [ "v*", "V*" ]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
@ -65,3 +72,23 @@ jobs:
with:
name: app-release
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 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
* Injects GoonCord client into the Discord mobile app.
* Built in Kotlin.
* Material 3 like Shake menu with some discord popup accents
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).
---
## 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
To use this module, your device must meet the following requirements:
* **Root Access**: Provided by Magisk, KernelSU, KernelSUNext, or Sukisu Ultra.
* **Xposed Framework**: Use JingMatrix Fork of [LSPosed](https://github.com/JingMatrix/LSPosed).
* **Target Application**: An official Discord application (stable, beta, or alpha version).
Before installing and using GoonXposed:
- 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
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)
- Any other issues: Same server as above, listing them all is not worth it.
## Using the Module
# 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.
· JingMatrix for their LSPosed fork.
· [cocobo1](https://codeberg.org/cocobo1) for adjusting the Xposed module for Kettu client mod.
· [Revenge Team](https://github.com/revenge-mod) for making a really good Xposed Module to base of
Themes:
- Pick appearance: System / Light / Dark
- Choose Menu Color Flavor: blue, green, mocha, vanilla, purple, amber, teal
- 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)

View file

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

View file

@ -16,6 +16,9 @@
android:value="Xposed module to inject GoonCord into discord"
/>
<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>
</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.no_track.BlockCrashReportingModule
import GoonXposed.xposed.modules.no_track.BlockDeepLinksTrackingModule
import GoonXposed.xposed.modules.LogBox.*
import kotlinx.coroutines.CompletableDeferred
object HookStateHolder {
@ -49,7 +50,8 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
PerfPatchesModule,
FontsModule,
ThemesModule,
SysColorsModule
SysColorsModule,
GmsCompatModule
)
init {
@ -57,13 +59,41 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
}
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) {
if (!isTargetPackage(packageName)) return
if (processName.contains(":")) 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) {
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 {
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) {
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)
hooked = true
}
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) {
for (module in modules) module.onContext(context)
dispatch("onContext") { module -> module.onContext(context) }
}
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.content.Context
import android.content.pm.PackageManager
import android.os.Build
import de.robv.android.xposed.IXposedHookZygoteInit
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Utils.Log
import kotlinx.serialization.json.JsonObjectBuilder
import java.io.File
import java.lang.reflect.Method
data class AppInfo(
val name: String,
val packageName: String,
val version: String,
val versionCode: Long,
)
/**
* 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.
*/
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) {}
/**
* Called when a package is loaded.
*
* Hooks should typically be set up here.
*
* Bridge methods that do not require [Context] can also be registered here.
*/
open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {}
/**
* Called after [Context] has been attached to a [android.content.ContextWrapper]
* via [android.content.ContextWrapper.attachBaseContext].
*
* This may be called multiple times for different [Context]s, e.g. when the app is killed by the system.
*
* For bridge methods that require access to [Context], register them here.
*/
open fun onContext(context: Context) {}
/**
* Called after an [Activity.onCreate] method is executed.
*
* UI code that requires an [Activity] context can be placed here.
*
* Bridge methods must not be registered here, as it can be too late and cause crashes.
*
* If you need to register bridge methods that require an [Activity], do it in [onContext] and make it async.
*
* Once an [Activity] is available, execute the queued calls. Then from JS, call the method asynchronously.
*/
open fun onActivity(activity: Activity) {}
@Deprecated("This method is deprecated in the parent class")
open fun buildPayload(builder: JsonObjectBuilder) {}
open fun onResume(activity: Activity) {}
open fun onPause(activity: Activity) {}
}
/**
* Runtime wrapper around an [XC_MethodHook.MethodHookParam] used as the receiver of every
* `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?
get() = param.thisObject
@ -80,12 +132,12 @@ class MethodHookBuilder {
fun build(): XC_MethodHook = object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
val scope = HookScope(param)
val scope = HookScope(param = param, proceed = { p -> super.beforeHookedMethod(p) })
beforeCallbacks.forEach { scope.run(it) }
}
override fun afterHookedMethod(param: MethodHookParam) {
val scope = HookScope(param)
val scope = HookScope(param = param, proceed = { p -> super.afterHookedMethod(p) })
afterCallbacks.forEach { scope.run(it) }
}
}
@ -104,28 +156,27 @@ fun ClassLoader.safeLoadClass(name: String): Class<*>? = try {
null
}
fun Class<*>.method(name: String, vararg parameterTypes: Class<*>): Method =
fun Class<*>.method(name: String, vararg parameterTypes: Class<*>?): Method =
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()
builder.block()
XposedBridge.hookMethod(this, builder.build())
}
fun Method.hook(hook: XC_MethodHook) {
XposedBridge.hookMethod(this, hook)
return XposedBridge.hookMethod(this, builder.build())
}
fun Class<*>.hookMethod(
name: String,
vararg parameterTypes: Class<*>,
vararg parameterTypes: Class<*>?,
block: MethodHookBuilder.() -> Unit
): Method {
): XC_MethodHook.Unhook? = runCatching {
val method = method(name, *parameterTypes)
method.hook(block)
return method
}
}.onFailure {
Log.w("Failed to hook method $name on ${this.name}: ${it.message}")
}.getOrNull()
fun File.asDir(): File {
if (!exists() || !isDirectory) mkdirs()
@ -137,13 +188,6 @@ fun File.asFile(): File {
return this
}
data class AppInfo(
val name: String,
val packageName: String,
val version: String,
val versionCode: Int
)
fun Context.getAppInfo(): AppInfo {
val label = applicationInfo.loadLabel(packageManager).toString()
val packageInfo = try {
@ -152,10 +196,15 @@ fun Context.getAppInfo(): AppInfo {
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(
name = label,
packageName = packageName,
version = packageInfo?.versionName ?: "",
versionCode = packageInfo?.versionCode ?: 0
versionCode = versionCode
)
}

View file

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

View file

@ -1,6 +1,5 @@
package GoonXposed.xposed.modules
import GoonXposed.xposed.hookMethod
import android.content.res.Resources
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE
@ -17,7 +16,13 @@ object FixResourcesModule : Module() {
"getIdentifier", String::class.java, String::class.java, String::class.java
) {
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.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.io.File
import java.lang.reflect.Method
@ -58,46 +59,53 @@ object HookScriptLoaderModule : Module() {
).mapNotNull { classLoader.safeLoadClass(it) }.forEach { hook(it) }
}
private fun hook(instance: Class<*>) = runCatching {
val loadScriptFromAssets = instance.method(
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
)
private fun hook(instance: Class<*>) {
val loadScriptFromAssets = runCatching {
instance.method(
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType
)
}.getOrNull()
val loadScriptFromFile = instance.method(
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
)
val loadScriptFromFile = runCatching {
instance.method(
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType
)
}.getOrNull()
loadScriptFromAssets.hook {
loadScriptFromAssets?.hook {
before {
Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})")
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
}
}
loadScriptFromFile.hook {
loadScriptFromFile?.hook {
before {
Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
}
}
}.onFailure {
Log.e("Failed to hook script loading methods in ${instance.name}:", it)
}
private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) {
private fun HookScope.runCustomScripts(loadScriptFromFile: Method?, loadScriptFromAssets: Method?) {
Log.i("Running custom scripts...")
runBlocking {
val ready = async { HookStateHolder.readyDeferred.join() }
val isCustomUrl = UpdaterModule.isCustomUrlEnabled
if (!mainScript.exists()) {
Log.i("Main script does not exist, downloading before load...")
val download = async { UpdaterModule.downloadScript().join() }
awaitAll(ready, download)
if (!mainScript.exists() || isCustomUrl) {
val reason = if (isCustomUrl) "Custom URL enabled" else "Main script does not exist"
Log.i("$reason, downloading before load...")
try {
withTimeout(10000L) {
UpdaterModule.downloadScript(showUpdateDialog = false).join()
}
} catch (e: Throwable) {
Log.w("Bundle download did not complete in time", e)
}
} else {
Log.i("Main script exists, updating in background...")
UpdaterModule.downloadScript()
ready.await()
UpdaterModule.downloadScript(showUpdateDialog = true)
}
}
@ -105,32 +113,48 @@ object HookScriptLoaderModule : Module() {
val runScriptFile = { file: File ->
Log.i("Loading script: ${file.absolutePath}")
XposedBridge.invokeOriginalMethod(
loadScriptFromFile, thisObject, arrayOf(file.absolutePath, file.absolutePath, loadSynchronously)
)
if (loadScriptFromFile != null) {
XposedBridge.invokeOriginalMethod(
loadScriptFromFile, thisObject, arrayOf(file.absolutePath, file.absolutePath, loadSynchronously)
)
}
Unit
}
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")
if (disabledMarker.exists()) {
Log.i("Script loading disabled by marker; skipping preloads, cached bundle and fallback")
} else {
// Normal behaviour: run preloads then cached main script or fallback to bundled asset
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
if (mainScript.exists()) {
runScriptFile(mainScript)
} 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)
XposedBridge.invokeOriginalMethod(
loadScriptFromAssets,
thisObject,
arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously)
)
var hasFallback = false
try {
resources.assets.open("Shiggy.bundle").use { hasFallback = true }
} 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) {

View file

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

View file

@ -3,6 +3,7 @@ package GoonXposed.xposed.modules.LogBox
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.Utils.Companion.reloadApp
import android.app.Activity
import android.content.Context
import de.robv.android.xposed.XC_MethodHook
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.callbacks.XC_LoadPackage
import kotlinx.coroutines.*
import java.lang.ref.WeakReference
object LogBoxModule : Module() {
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
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) {
this@LogBoxModule.packageParam = packageParam
try {
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
val getUseDeveloperSupportMethod =
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}")
}
// Do not force getUseDeveloperSupport = true, as it causes React Native to loop
// attempting to connect to a Metro bundler on localhost:8081 and hang the app loading screen.
// Shake detector and recovery menu work independently without it.
return@with
}
@ -126,7 +151,7 @@ object LogBoxModule : Module() {
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})")
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.Module
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.hook
import GoonXposed.xposed.method
import GoonXposed.xposed.safeLoadClass
@ -27,7 +28,11 @@ class PayloadGlobalModule(private val modules: List<Module>) : Module() {
buildJsonObject {
put("loaderName", Constants.LOADER_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) {
@ -52,7 +57,7 @@ class PayloadGlobalModule(private val modules: List<Module>) : Module() {
"loadScriptFromFile",
String::class.java,
String::class.java,
Boolean::class.javaPrimitiveType!!
Boolean::class.javaPrimitiveType
), 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) {
instance.method(
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType!!,
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType,
).hook(hook)
instance.method(
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType
).hook(hook)
}
}

View file

@ -25,293 +25,20 @@ object PerfPatchesModule : Module() {
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
try {
// Build preloads path inside the app data files directory
val filesDir = File(packageParam.appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
val preloadsDir = File(filesDir, HookScriptLoaderModule.PRELOADS_DIR).apply { mkdirs() }
val filesDir = File(packageParam.appInfo.dataDir, Constants.FILES_DIR)
val preloadsDir = File(filesDir, HookScriptLoaderModule.PRELOADS_DIR)
val out = File(preloadsDir, FILE_NAME)
// Defensive JS runtime patch. Avoid template literals with ${} to prevent Kotlin interpolation.
val 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)
if (out.exists()) {
out.delete()
Log.i("PerfPatchesModule: deleted legacy perf_patches.js")
}
} catch (e: Throwable) {
Log.e("PerfPatchesModule:onLoad failed", e)
Log.e("PerfPatchesModule: cleanup failed", e)
}
}
@Deprecated("Overrides deprecated Module.buildPayload")
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 CONFIG_FILE = "loader.json"
val isCustomUrlEnabled: Boolean
get() = if (::config.isInitialized) config.customLoadUrl.enabled else false
private const val DEFAULT_BUNDLE_URL =
"https://github.com/OTKUSteyler/GoonCord/releases/latest/download/gooncord.js"
@ -77,7 +80,7 @@ object UpdaterModule : Module() {
}.getOrDefault(LoaderConfig())
}
fun downloadScript(activity: Activity? = null): Job = scope.launch {
fun downloadScript(activity: Activity? = null, showUpdateDialog: Boolean = true): Job = scope.launch {
try {
// Respect LogBox setting to disable bundle injections.
// 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)")
// This is a retry, so we show a dialog
if (activity != null) {
withContext(Dispatchers.Main) {
AlertDialog.Builder(activity).setTitle("GoonCord Update Successful")
@ -139,6 +141,22 @@ object UpdaterModule : Module() {
dialog.dismiss()
}.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
import android.content.res.AssetManager
@ -15,6 +13,8 @@ import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.asDir
import GoonXposed.xposed.asFile
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
@ -29,14 +29,6 @@ import kotlinx.serialization.json.put
import java.io.File
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
data class FontDefinition(
val name: String? = null,
@ -50,6 +42,11 @@ object FontsModule : Module() {
private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
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 fontsDownloadsDir: File
private var fontsAbsPath: String? = null
@ -61,21 +58,33 @@ object FontsModule : Module() {
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
XposedHelpers.findAndHookMethod(
"com.facebook.react.common.assets.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 replacement = 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)
}
}
// React Native moved ReactFontManager between releases, so try every known package.
// Never let this throw: an uncaught error here aborts the whole module chain.
val hooked = FONT_MANAGER_CLASSES.any { className ->
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() }
if (!fontDefFile.exists()) return@with
@ -103,40 +112,28 @@ object FontsModule : Module() {
// These files should be downloaded by the JS side, but oh well
CoroutineScope(Dispatchers.IO).launch {
val client = HttpClient(CIO) {
install(UserAgent) { agent = Constants.USER_AGENT }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 15_000
}
}
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
try {
fontDef.main.keys.map { name ->
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 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()
} finally {
client.close()
}
}
}.awaitAll()
}
}

View file

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

View file

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