Compare commits

..

No commits in common. "master" and "V1.3.0" have entirely different histories.

54 changed files with 2210 additions and 5650 deletions

View file

@ -1,5 +1,4 @@
name: Build APK name: Build APK
on: [push] on: [push]
jobs: jobs:
@ -12,16 +11,15 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Java - uses: actions/setup-java@v3
uses: actions/setup-java@v4
with: with:
distribution: "temurin" distribution: "temurin"
java-version: "17" java-version: "17"
- name: Make Gradle executable - name: "Grant execution permission"
run: chmod +x ./gradlew run: chmod +x ./gradlew
- name: Build APKs - name: "Build APKs"
run: | run: |
# Retry the Gradle build up to 3 times to mitigate transient cache/service errors # Retry the Gradle build up to 3 times to mitigate transient cache/service errors
ATTEMPT=0 ATTEMPT=0
@ -37,30 +35,30 @@ jobs:
exit 1 exit 1
fi fi
- name: Setup directories - name: "Setup directories"
run: run: |
mkdir -p /tmp/ShiggyXposedbuild/apks mkdir -p /tmp/ShiggyXposedbuild/apks
mkdir -p /tmp/ShiggyXposedbuild/tools mkdir -p /tmp/ShiggyXposedbuild/tools
- name: Move debug APK - name: "Move debug APK"
run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/ShiggyXposedbuild/apks run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/ShiggyXposedbuild/apks
- name: Download uber-apk-signer - name: "Download uber-apk-signer"
run: wget -nv "https://github.com/patrickfav/uber-apk-signer/releases/download/v1.2.1/uber-apk-signer-1.2.1.jar" -O /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar run: wget -nv "https://github.com/patrickfav/uber-apk-signer/releases/download/v1.2.1/uber-apk-signer-1.2.1.jar" -O /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar
- name: Sign release APK - name: "Sign release APK"
run: java -jar /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/ShiggyXposedbuild/apks/ run: java -jar /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/ShiggyXposedbuild/apks/
- name: Rename release APK - name: "Rename release APK"
run: mv /tmp/ShiggyXposedbuild/apks/app-release-aligned-debugSigned.apk /tmp/ShiggyXposedbuild/apks/app-release.apk run: mv /tmp/ShiggyXposedbuild/apks/app-release-aligned-debugSigned.apk /tmp/ShiggyXposedbuild/apks/app-release.apk
- name: Upload debug APK - name: "Upload debug APK"
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: app-debug name: app-debug
path: /tmp/ShiggyXposedbuild/apks/app-debug.apk path: /tmp/ShiggyXposedbuild/apks/app-debug.apk
- name: Upload All APKs - name: "Upload release APK"
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: app-release name: app-release

128
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,128 @@
name: Build Release
on:
workflow_dispatch:
inputs:
versionName:
required: true
description: This releases version name
default: "1.0.0"
versionCode:
required: true
description: This releases version code
default: "1000"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup JDK 17
uses: actions/setup-java@v3
with:
distribution: "temurin"
java-version: 17
- name: Setup Android SDK
uses: android-actions/setup-android@v2.0.10
- name: Set Version
uses: chkfung/android-version-actions@v1.1
with:
gradlePath: app/build.gradle.kts
versionCode: ${{github.event.inputs.versionCode}}
versionName: ${{github.event.inputs.versionName}}
- name: Bump Version
continue-on-error: true
run: |
git config --local user.email "actions@github.com"
git config --local user.name "GitHub Actions"
git add app/build.gradle.kts
if ! git diff --quiet app/build.gradle.kts; then
git commit -m "chore: Bump version to ${{github.event.inputs.versionName}} (${{github.event.inputs.versionCode}})"
git push
else
echo "No changes to version file, skipping commit"
fi
- name: Build Signed APK
run: |
# Ensure keystore secret is present
if [ -z "${{ secrets.keystore }}" ]; then
echo "Keystore secret missing, aborting"
exit 1
fi
echo "${{ secrets.keystore }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks
chmod +x ./gradlew
# Retry gradle up to 3 times to mitigate transient network/cache service issues
ATTEMPT=0
until [ $ATTEMPT -ge 3 ]
do
./gradlew packageReleaseUniversalApk \
-Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \
-Pandroid.injected.signing.store.password=${{ secrets.keystore_password }} \
-Pandroid.injected.signing.key.alias=${{ secrets.key_alias }} \
-Pandroid.injected.signing.key.password=${{ secrets.key_password }} --no-daemon --stacktrace && break
ATTEMPT=$((ATTEMPT+1))
echo "Gradle build failed, retrying ($ATTEMPT/3)..."
sleep 5
done
if [ $ATTEMPT -ge 3 ]; then
echo "Gradle build failed after 3 attempts"
exit 1
fi
- name: Release
run: |
set -e
echo "Locating built APK(s) under app/build/outputs..."
# Find APK files produced by the build in common output locations
apk_paths=$(find app/build/outputs -type f -name "*.apk" 2>/dev/null || true)
if [ -z "$apk_paths" ]; then
echo "ERROR: No APK files found under app/build/outputs"
echo "Diagnostics: top-level listing of app/build/outputs:"
ls -la app/build/outputs || true
echo "Diagnostics: recursive listing (files only):"
find app/build/outputs -type f -maxdepth 6 -print || true
echo "Failing the job so you can inspect the build logs and diagnostics above."
exit 1
fi
# Prepare a directory to collect APKs for the release
mkdir -p release-apks
echo "Copying found APK(s) to release-apks/ for upload:"
for apk in $apk_paths; do
echo " - $apk"
cp "$apk" "release-apks/$(basename "$apk")"
done
# Keep backward compatibility: if exactly one APK found, copy it to the root as app-release.apk
apk_count=$(echo "$apk_paths" | wc -w)
if [ "$apk_count" -eq 1 ]; then
single_apk=$(echo "$apk_paths")
cp "$single_apk" ./app-release.apk
echo "Single APK copied to ./app-release.apk"
else
echo "Multiple APKs found ($apk_count). All copied to release-apks/"
fi
git config --local user.email "actions@github.com"
git config --local user.name "GitHub Actions"
tag="${{ github.event.inputs.versionCode }}"
git tag "$tag"
git push origin "$tag"
# Create the GitHub release including all APK(s) we collected (debug + release)
gh release create "$tag" \
--title "${{ github.event.inputs.versionName }}" \
--generate-notes \
release-apks/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View file

@ -15,4 +15,3 @@ build
local.properties local.properties
app/release/release/ app/release/release/
release release
release.keystore

3
.idea/.gitignore generated vendored
View file

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View file

@ -1,40 +0,0 @@
# GoonXposed
GoonXposed is a root Xposed module that injects the GoonCord Discord mobile client modifications into the official Discord application.
## Info
* Injects GoonCord client into the Discord mobile app.
* Built in Kotlin.
* Material 3 like Shake menu with some discord popup accents
## 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).
## 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.
# 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.
# Thanks & Credits
This project was made possible thanks to the work of:
· 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
![OTKUSteyler](https://count.getloli.com/@OTKUSteyler?name=OTKUSteyler&theme=miku&padding=7&offset=0&align=top&scale=1&pixelated=1&darkmode=1)

View file

@ -12,20 +12,8 @@ android {
applicationId = "GoonXposed.xposed" applicationId = "GoonXposed.xposed"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 1358 versionCode = 1200
versionName = "1.3.8" versionName = "1.2.0"
}
signingConfigs {
create("release") {
val keystoreFile = file("../release.keystore")
if (keystoreFile.exists()) {
storeFile = keystoreFile
storePassword = "goonxposed"
keyAlias = "goonxposed"
keyPassword = "goonxposed"
}
}
} }
buildTypes { buildTypes {
@ -36,38 +24,37 @@ android {
} }
release { release {
isDebuggable = false isDebuggable = false
isMinifyEnabled = true isMinifyEnabled = false
val keystoreFile = file("../release.keystore")
if (keystoreFile.exists()) {
signingConfig = signingConfigs.getByName("release")
}
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
} }
kotlinOptions { jvmTarget = "17" }
buildFeatures { buildConfig = true }
} }
tasks.withType<org.gradle.api.tasks.compile.JavaCompile>().configureEach { repositories {
options.compilerArgs.add("-Xlint:-options") mavenCentral()
google()
maven("https://jitpack.io")
} }
dependencies { dependencies {
compileOnly("de.robv.android.xposed:api:82") compileOnly("de.robv.android.xposed:api:82")
implementation("androidx.core:core-ktx:1.17.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0")
implementation("io.ktor:ktor-client-core:3.3.1") implementation("io.ktor:ktor-client-core:3.3.0")
implementation("io.ktor:ktor-client-cio:3.3.1") implementation("io.ktor:ktor-client-cio:3.3.0")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
implementation("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.rikka.shizuku:provider:13.1.5")
} }

View file

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<manifest <manifest
package="GoonXposed.xposed"
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools">
>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:label="@string/app_name" android:label="@string/app_name"

View file

@ -1,108 +0,0 @@
package GoonXposed.xposed
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.os.Bundle
import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.IXposedHookZygoteInit
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.modules.*
import GoonXposed.xposed.modules.appearance.FontsModule
import GoonXposed.xposed.modules.appearance.SysColorsModule
import GoonXposed.xposed.modules.appearance.ThemesModule
import GoonXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule
import GoonXposed.xposed.modules.bridge.BridgeModule
import GoonXposed.xposed.modules.no_track.BlockCrashReportingModule
import GoonXposed.xposed.modules.no_track.BlockDeepLinksTrackingModule
import kotlinx.coroutines.CompletableDeferred
object HookStateHolder {
/**
* Whether all hooks are completed, and we are ready to load the JS bundle.
*/
val readyDeferred = CompletableDeferred<Unit>()
/**
* Whether we have successfully received a [Context] yet.
* Sometimes the app process is recreated and Xposed hooks way too late for us to get [Context] from [ContextWrapper.attachBaseContext].
* But since Xposed hooks before [Activity.onCreate], we can still get it from there and still initialize properly.
*/
@Volatile
var gotContext = false
}
class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
private var hooked = false
private val modules = mutableListOf(
HookScriptLoaderModule,
BridgeModule,
AdditionalBridgeMethodsModule,
PluginsModule(),
UpdaterModule,
FixResourcesModule,
BlockDeepLinksTrackingModule,
BlockCrashReportingModule,
LogBoxModule,
CacheModule,
PerfPatchesModule,
FontsModule,
ThemesModule,
SysColorsModule
)
init {
modules += PayloadGlobalModule(modules)
}
override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
for (module in modules) module.onInit(startupParam)
}
override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with(param) {
if (hooked) return
val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY)
ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) {
after {
val ctx = args[0] as Context
HookStateHolder.gotContext = true
Log.i("Received Context")
this@Main.onContext(ctx)
}
}
reactActivity.hookMethod("onCreate", Bundle::class.java) {
after {
val act = thisObject as Activity
Log.i("Received Activity")
if (!HookStateHolder.gotContext) {
Log.w("Activity created before we got Context, process may have been recreated!")
this@Main.onContext(act.applicationContext)
}
this@Main.onActivity(act)
HookStateHolder.readyDeferred.complete(Unit)
}
}
this@Main.onLoad(param)
hooked = true
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
for (module in modules) module.onLoad(packageParam)
}
override fun onContext(context: Context) {
for (module in modules) module.onContext(context)
}
override fun onActivity(activity: Activity) {
for (module in modules) module.onActivity(activity)
}
}

View file

@ -1,161 +0,0 @@
package GoonXposed.xposed
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
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 kotlinx.serialization.json.JsonObjectBuilder
import java.io.File
import java.lang.reflect.Method
/**
* Base class for every Xposed side module.
*
* The [Main] loader forwards the various [XC_LoadPackage] lifecycle callbacks to every
* registered [Module] instance. Modules may override any subset of the hooks below.
*/
open class Module {
open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {}
open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {}
open fun onContext(context: Context) {}
open fun onActivity(activity: Activity) {}
@Deprecated("This method is deprecated in the parent class")
open fun buildPayload(builder: JsonObjectBuilder) {}
}
/**
* 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) {
val thisObject: Any?
get() = param.thisObject
val args: Array<Any?>
get() = param.args
var result: Any?
get() = param.result
set(value) {
param.result = value
}
var throwable: Throwable?
get() = param.throwable
set(value) {
param.throwable = value
}
}
/**
* DSL builder for lazily constructed [XC_MethodHook] instances.
*
* ```kotlin
* val hook = MethodHookBuilder().run {
* before { result = null }
* after { Log.i("done") }
* build()
* }
* ```
*/
class MethodHookBuilder {
private val beforeCallbacks = mutableListOf<HookScope.() -> Unit>()
private val afterCallbacks = mutableListOf<HookScope.() -> Unit>()
fun before(callback: HookScope.() -> Unit) {
beforeCallbacks += callback
}
fun after(callback: HookScope.() -> Unit) {
afterCallbacks += callback
}
fun build(): XC_MethodHook = object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
val scope = HookScope(param)
beforeCallbacks.forEach { scope.run(it) }
}
override fun afterHookedMethod(param: MethodHookParam) {
val scope = HookScope(param)
afterCallbacks.forEach { scope.run(it) }
}
}
companion object {
fun from(block: MethodHookBuilder.() -> Unit): XC_MethodHook = MethodHookBuilder().run {
block()
build()
}
}
}
fun ClassLoader.safeLoadClass(name: String): Class<*>? = try {
loadClass(name)
} catch (e: Throwable) {
null
}
fun Class<*>.method(name: String, vararg parameterTypes: Class<*>): Method =
XposedHelpers.findMethodExact(this, name, *parameterTypes)
fun Method.hook(block: MethodHookBuilder.() -> Unit) {
val builder = MethodHookBuilder()
builder.block()
XposedBridge.hookMethod(this, builder.build())
}
fun Method.hook(hook: XC_MethodHook) {
XposedBridge.hookMethod(this, hook)
}
fun Class<*>.hookMethod(
name: String,
vararg parameterTypes: Class<*>,
block: MethodHookBuilder.() -> Unit
): Method {
val method = method(name, *parameterTypes)
method.hook(block)
return method
}
fun File.asDir(): File {
if (!exists() || !isDirectory) mkdirs()
return this
}
fun File.asFile(): File {
parentFile?.mkdirs()
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 {
packageManager.getPackageInfo(packageName, 0)
} catch (e: PackageManager.NameNotFoundException) {
null
}
return AppInfo(
name = label,
packageName = packageName,
version = packageInfo?.versionName ?: "",
versionCode = packageInfo?.versionCode ?: 0
)
}

View file

@ -1,56 +0,0 @@
package GoonXposed.xposed
import android.app.AlertDialog
import android.app.Application
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
import kotlin.system.exitProcess
class Utils {
companion object {
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 intent = application.packageManager.getLaunchIntentForPackage(application.packageName)
application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
exitProcess(0)
}
fun showRecoveryAlert(context: Context) {
AlertDialog.Builder(context).setTitle("GoonCord Recovery Options")
.setItems(arrayOf("Reload", "Delete Script", "Reset Loader Config")) { _, which ->
when (which) {
0 -> {
reloadApp()
}
1 -> {
val bundleFile = File(
context.dataDir, "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
)
if (bundleFile.exists()) bundleFile.delete()
reloadApp()
}
}
}.show()
}
}
object Log {
fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg)
fun e(msg: String, throwable: Throwable) = android.util.Log.e(Constants.LOG_TAG, msg, throwable)
fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg)
fun i(msg: String, throwable: Throwable) = android.util.Log.i(Constants.LOG_TAG, msg, throwable)
fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg)
fun w(msg: String, throwable: Throwable) = android.util.Log.w(Constants.LOG_TAG, msg, throwable)
}
}

View file

@ -1,273 +0,0 @@
package GoonXposed.xposed.modules
import android.content.Context
import android.util.AtomicFile
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.asDir
import GoonXposed.xposed.asFile
import GoonXposed.xposed.getAppInfo
import GoonXposed.xposed.modules.bridge.BridgeModule
import java.io.*
/**
* A module for caching versioned modules finds and blacklist, and assets mappings.
*
* See [ModulesCache] and [AssetsCache] for the cache structure.
*
* ## Methods
*
* ### Modules cache
*
* - `Shiggy.caches.modules.read() : { blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }, version: number } | null`
* - Reads the modules cache. Returns `null` if the cache file does not exist or is invalid.
*
* - `Shiggy.caches.modules.write(blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }) : void`
* - Writes the modules cache.
*
* ### Assets cache
*
* - `Shiggy.caches.assets.read() : { data: { [name: string]: { [type: string]: number } }, version: number } | null`
* - Reads the assets cache. Returns `null` if the cache file does not exist or is invalid.
*
* - `Shiggy.caches.assets.write(data: { [name: string]: { [type: string]: number } }) : void`
* - Writes the assets cache.
*
* The caches are versioned and tied to the app version code. When the app is updated,
* the caches are invalidated and new cache files are created.
*/
object CacheModule : Module() {
private const val CACHE_DIR = "Goon"
private const val MODULES_CACHE_PREFIX = "modules"
private const val ASSETS_CACHE_PREFIX = "assets"
private lateinit var modulesCache: ModulesCache
private lateinit var assetsCache: AssetsCache
override fun onContext(context: Context) = with(context) {
val ShiggyCacheDir = File(cacheDir, CACHE_DIR).apply { asDir() }
val (_, _, _, versionCode) = getAppInfo()
val modulesCacheFile = File(
ShiggyCacheDir, "$MODULES_CACHE_PREFIX.$versionCode"
).apply { asFile() }
val assetsCacheFile = File(
ShiggyCacheDir, "$ASSETS_CACHE_PREFIX.$versionCode"
).apply { asFile() }
BridgeModule.registerMethod("Shiggy.caches.modules.read") {
if (::modulesCache.isInitialized) modulesCache.toMap() else ModulesCache.loadFromFileOrNull(modulesCacheFile)
?.let {
modulesCache = it
it.toMap()
}
}
BridgeModule.registerMethod("Shiggy.caches.modules.write") {
val (blacklist, finds) = it
@Suppress("UNCHECKED_CAST")
modulesCache = ModulesCache(
blacklist as ArrayList<Double>, finds as HashMap<String, HashMap<String, Double>?>
).apply { saveToFile(modulesCacheFile) }
Log.i("Modules cache saved: ${modulesCacheFile.absolutePath} (blacklisted: ${blacklist.size}, finds: ${finds.size})")
}
BridgeModule.registerMethod("Shiggy.caches.assets.read") {
if (::assetsCache.isInitialized) assetsCache.toMap() else AssetsCache.loadFromFileOrNull(assetsCacheFile)
?.let {
assetsCache = it
it.toMap()
}
}
BridgeModule.registerMethod("Shiggy.caches.assets.write") {
val (data) = it
@Suppress("UNCHECKED_CAST")
assetsCache =
AssetsCache(data as HashMap<String, HashMap<String, Double>>).apply { saveToFile(assetsCacheFile) }
Log.i("Assets cache saved: ${assetsCacheFile.absolutePath} (count: ${data.size})")
}
}
}
private class CacheVersionMismatchException(expected: Int, actual: Int) :
Throwable("Expected cache version: $expected, but got version: $actual")
data class ModulesCache(
val blacklist: List<Double>, val finds: Map<String, Map<String, Double>?>
) {
fun saveToFile(file: File) {
val atomic = AtomicFile(file)
var fos: FileOutputStream? = null
try {
fos = atomic.startWrite()
val out = DataOutputStream(BufferedOutputStream(fos))
out.writeInt(VERSION)
// Write blacklist
out.writeInt(blacklist.size)
for (num in blacklist) out.writeInt(num.toInt())
// Write finds
out.writeInt(finds.size)
for ((filter, matches) in finds) {
out.writeUTF(filter)
if (matches == null) {
out.writeBoolean(false) // null marker
} else {
out.writeBoolean(true) // not null
out.writeInt(matches.size)
for ((id, flag) in matches) {
out.writeInt(id.toInt())
out.writeInt(flag.toInt())
}
}
}
out.flush()
atomic.finishWrite(fos)
} catch (t: Throwable) {
if (fos != null) atomic.failWrite(fos)
throw t
}
}
fun toMap(): Map<String, Any> {
val findsMap = finds.mapValues { (_, matches) -> matches?.map { (k, v) -> k to v }?.toMap() }
return mapOf("blacklist" to blacklist, "finds" to findsMap, "version" to VERSION)
}
companion object {
const val VERSION = 3
fun loadFromFileOrNull(file: File): ModulesCache? {
if (!file.exists() || file.length() <= 0L) return null
val atomic = AtomicFile(file)
try {
DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
val version = input.readInt()
if (version != VERSION) throw CacheVersionMismatchException(VERSION, version)
val blacklistSize = input.readInt()
val blacklist = MutableList(blacklistSize) { input.readInt().toDouble() }
val findsSize = input.readInt()
val finds = mutableMapOf<String, Map<String, Double>?>()
repeat(findsSize) {
val filter = input.readUTF()
val hasMatches = input.readBoolean()
if (!hasMatches) {
finds[filter] = null
} else {
val mapSize = input.readInt()
val matches = mutableMapOf<String, Double>()
repeat(mapSize) {
val id = input.readInt().toString()
val flag = input.readInt().toDouble()
matches[id] = flag
}
finds[filter] = matches
}
}
return ModulesCache(blacklist, finds)
}
} catch (e: CacheVersionMismatchException) {
Log.i("Modules cache version mismatch: ${e.message}")
} catch (e: EOFException) {
Log.e("Modules cache corrupt: ${e.message}")
} catch (e: IOException) {
Log.e("Failed to read modules cache: ${e.message}")
}
runCatching { file.delete() }
return null
}
}
}
data class AssetsCache(
val data: Map<String, Map<String, Double>>
) {
fun saveToFile(file: File) {
val atomic = AtomicFile(file)
var fos: FileOutputStream? = null
try {
fos = atomic.startWrite()
val out = DataOutputStream(BufferedOutputStream(fos))
out.writeInt(VERSION)
out.writeInt(data.size)
for ((name, mappings) in data) {
out.writeUTF(name)
out.writeInt(mappings.size)
for ((type, id) in mappings) {
out.writeUTF(type)
out.writeInt(id.toInt())
}
}
out.flush()
atomic.finishWrite(fos)
} catch (t: Throwable) {
if (fos != null) atomic.failWrite(fos)
throw t
}
}
fun toMap(): Map<String, Any> {
return mapOf(
"data" to data.mapValues { (_, mappings) ->
mappings.map { (type, id) -> type to id }.toMap()
}, "version" to VERSION
)
}
companion object {
const val VERSION = 2
fun loadFromFileOrNull(file: File): AssetsCache? {
if (!file.exists() || file.length() <= 0L) return null
val atomic = AtomicFile(file)
try {
DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
val version = input.readInt()
if (version != VERSION) throw CacheVersionMismatchException(VERSION, version)
val dataSize = input.readInt()
val data = mutableMapOf<String, Map<String, Double>>()
repeat(dataSize) {
val name = input.readUTF()
val mappingsSize = input.readInt()
val mappings = mutableMapOf<String, Double>()
repeat(mappingsSize) {
val type = input.readUTF()
val id = input.readInt().toDouble()
mappings[type] = id
}
data[name] = mappings
}
return AssetsCache(data)
}
} catch (e: CacheVersionMismatchException) {
Log.i("Assets cache version mismatch: ${e.message}")
} catch (e: EOFException) {
Log.e("Assets cache corrupt: ${e.message}")
} catch (e: IOException) {
Log.e("Failed to read assets cache: ${e.message}")
}
runCatching { file.delete() }
return null
}
}
}

View file

@ -1,25 +0,0 @@
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
import GoonXposed.xposed.Module
import GoonXposed.xposed.hookMethod
/**
* Hooks [Resources.getIdentifier] to fix resource package name mismatch.
*/
object FixResourcesModule : Module() {
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
if (packageName != TARGET_PACKAGE) {
Resources::class.java.hookMethod(
"getIdentifier", String::class.java, String::class.java, String::class.java
) {
before {
if (args[2] == packageName) args[2] = TARGET_PACKAGE
}
}
}
}
}

View file

@ -1,140 +0,0 @@
package GoonXposed.xposed.modules
import android.content.res.AssetManager
import android.content.res.XModuleResources
import de.robv.android.xposed.IXposedHookZygoteInit
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Constants
import GoonXposed.xposed.HookScope
import GoonXposed.xposed.HookStateHolder
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.asDir
import GoonXposed.xposed.asFile
import GoonXposed.xposed.hook
import GoonXposed.xposed.method
import GoonXposed.xposed.safeLoadClass
import GoonXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import java.io.File
import java.lang.reflect.Method
/**
* Hooks React Native's script loading methods to load custom scripts and bundles.
*
* Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files directory.
*
* The main bundle should be placed in the [Constants.CACHE_DIR] directory named [Constants.MAIN_SCRIPT_FILE].
* If the bundle file does not exist, it will attempt to load `assets://Shiggy.bundle` from the module's assets.
*/
object HookScriptLoaderModule : Module() {
private lateinit var preloadsDir: File
private lateinit var mainScript: File
private lateinit var modulePath: String
private lateinit var resources: XModuleResources
const val PRELOADS_DIR = "preloads"
override fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {
this@HookScriptLoaderModule.modulePath = startupParam.modulePath
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() }
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() }
preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() }
mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() }
listOf(
"com.facebook.react.runtime.ReactInstance\$loadJSBundle$1",
"com.facebook.react.runtime.ReactInstance$1",
// TODO: Remove once Discord fully switches to Bridgeless
"com.facebook.react.bridge.CatalystInstanceImpl"
).mapNotNull { classLoader.safeLoadClass(it) }.forEach { hook(it) }
}
private fun hook(instance: Class<*>) = runCatching {
val loadScriptFromAssets = instance.method(
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
)
val loadScriptFromFile = instance.method(
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
)
loadScriptFromAssets.hook {
before {
Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})")
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
}
}
loadScriptFromFile.hook {
before {
Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
}
}
}.onFailure {
Log.e("Failed to hook script loading methods in ${instance.name}:", it)
}
private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) {
Log.i("Running custom scripts...")
runBlocking {
val ready = async { HookStateHolder.readyDeferred.join() }
if (!mainScript.exists()) {
Log.i("Main script does not exist, downloading before load...")
val download = async { UpdaterModule.downloadScript().join() }
awaitAll(ready, download)
} else {
Log.i("Main script exists, updating in background...")
UpdaterModule.downloadScript()
ready.await()
}
}
val loadSynchronously = args[2]
val runScriptFile = { file: File ->
Log.i("Loading script: ${file.absolutePath}")
XposedBridge.invokeOriginalMethod(
loadScriptFromFile, thisObject, arrayOf(file.absolutePath, file.absolutePath, loadSynchronously)
)
Unit
}
try {
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 {
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
if (mainScript.exists()) {
runScriptFile(mainScript)
} else {
Log.i("Main script does not exist, falling back")
if (!::resources.isInitialized) resources = XModuleResources.createInstance(modulePath, null)
XposedBridge.invokeOriginalMethod(
loadScriptFromAssets,
thisObject,
arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously)
)
}
}
} catch (e: Throwable) {
Log.e("Unable to run scripts:", e)
}
}
}

View file

@ -1,170 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.app.AlertDialog
import android.content.Context
import android.text.InputType
import android.widget.*
import org.json.JSONObject
import java.io.File
object CustomBundleDialog {
fun showCustomBundleDialog(context: Context) {
val colors = LogBoxTheme.getM3Colors(context)
val filesDir = LogBoxUtils.getPyoncordDirectory(context)
val configFile = File(filesDir, "loader.json")
var currentUrl: String? = null
var isEnabled = false
if (configFile.exists()) {
try {
val json = JSONObject(configFile.readText())
val customLoadUrl = json.optJSONObject("customLoadUrl")
if (customLoadUrl != null) {
isEnabled = customLoadUrl.optBoolean("enabled", false)
currentUrl = customLoadUrl.optString("url", "")
}
} catch (_: Exception) {
}
}
val container = LogBoxComponents.createMenuContainer(context, colors)
container.addView(LogBoxComponents.createTitle(context, colors, "Custom Bundle URL"))
val toggleContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.CENTER_VERTICAL
val params = LinearLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, 0, LogBoxUtils.dpToPx(context, 16))
}
layoutParams = params
}
val toggleLabel = TextView(context).apply {
text = "Enable Custom URL"
textSize = 14f
setTextColor(colors.onSurface)
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
layoutParams = LinearLayout.LayoutParams(
0,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
}
val toggleSwitch = LogBoxComponents.createM3Switch(context, colors, isEnabled)
toggleContainer.addView(toggleLabel)
toggleContainer.addView(toggleSwitch)
container.addView(toggleContainer)
val urlInput = EditText(context).apply {
hint = "http://localhost:4040/gooncord.js"
setTextColor(colors.onSurface)
setHintTextColor(colors.onSurfaceVariant)
background = LogBoxUtils.createM3Background(context, colors.surfaceVariant, 12f)
inputType = InputType.TYPE_TEXT_VARIATION_URI
setText(currentUrl ?: "")
textSize = 14f
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 12),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 12)
)
alpha = if (isEnabled) 1f else 0.5f
val params = LinearLayout.LayoutParams(
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
}
layoutParams = params
}
container.addView(urlInput)
toggleSwitch.setOnClickListener {
val currentState = toggleSwitch.tag as Boolean
val newState = !currentState
toggleSwitch.tag = newState
LogBoxComponents.updateSwitchAppearance(context, toggleSwitch, colors, newState)
urlInput.animate()
.alpha(if (newState) 1f else 0.5f)
.setDuration(250)
.withEndAction {
urlInput.isEnabled = newState
}
.start()
}
val buttonContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.END
}
val cancelButton = TextView(context).apply {
text = "Cancel"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val saveButton = TextView(context).apply {
text = "Save"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val dialog = LogBoxDialogs.createDialog(context, container)
cancelButton.setOnClickListener { dialog.dismiss() }
saveButton.setOnClickListener {
try {
val url = urlInput.text?.toString()?.trim() ?: ""
val enabled = toggleSwitch.tag as Boolean
if (enabled && url.isNotEmpty()) {
LogBoxActions.setCustomBundleURL(context, url, true)
dialog.dismiss()
} else if (!enabled) {
LogBoxActions.setCustomBundleURL(
context,
url.ifEmpty { "http://localhost:4040/shiggycord.js" },
false
)
dialog.dismiss()
} else {
Toast.makeText(context, "Please enter a valid URL", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
val err = e.message ?: "unknown error"
Toast.makeText(context, "Failed to set custom bundle: $err", Toast.LENGTH_LONG).show()
}
}
buttonContainer.addView(cancelButton)
buttonContainer.addView(saveButton)
container.addView(buttonContainer)
LogBoxDialogs.currentMenuContainer = container
dialog.show()
LogBoxDialogs.setDialogWindowWidth(dialog, context)
}
}

View file

@ -1,35 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.app.AlertDialog
import android.content.Context
object FlavorSelectionMenu {
fun showFlavorSelection(context: Context) {
val colors = LogBoxTheme.getM3Colors(context)
lateinit var dialog: AlertDialog
val container = LogBoxComponents.createMenuContainer(context, colors, 20)
container.addView(LogBoxComponents.createTitle(context, colors, "Select Menu Flavor", center = true))
LogBoxConstants.FLAVORS.forEach { flavor ->
container.addView(
LogBoxComponents.createM3Button(
context,
flavor.replaceFirstChar { it.uppercase() },
colors
) {
try {
dialog.dismiss()
} catch (_: Exception) {
}
LogBoxUtils.saveMenuFlavor(context, flavor)
})
}
dialog = LogBoxDialogs.createDialog(context, container)
LogBoxDialogs.currentMenuContainer = container
dialog.show()
LogBoxDialogs.setDialogWindowWidth(dialog, context)
}
}

View file

@ -1,211 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.content.Context
import android.widget.Toast
import org.json.JSONObject
import java.io.File
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.Utils.Companion.reloadApp
import GoonXposed.xposed.Constants
import kotlinx.coroutines.*
object LogBoxActions {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun toggleSafeMode(context: Context) {
try {
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
val themeFile = File(context.filesDir, "logbox/LOGBOX_THEMES")
settingsFile.parentFile?.mkdirs()
val settings = if (settingsFile.exists()) {
JSONObject(settingsFile.readText())
} else {
JSONObject()
}
val safeMode = settings.optJSONObject("safeMode") ?: JSONObject()
val currentState = safeMode.optBoolean("enabled", false)
val newState = !currentState
safeMode.put("enabled", newState)
if (newState && themeFile.exists()) {
val theme = JSONObject(themeFile.readText())
val themeId = theme.optString("id")
if (themeId.isNotEmpty()) {
safeMode.put("currentThemeId", themeId)
themeFile.delete()
}
}
settings.put("safeMode", safeMode)
settingsFile.writeText(settings.toString())
Toast.makeText(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}", Toast.LENGTH_SHORT).show()
reloadApp()
} catch (e: Exception) {
Log.e("Error toggling safe mode: ${e.message}")
showError(context, "Failed to toggle safe mode", e.message)
}
}
fun toggleBundleInjection(context: Context) {
try {
val cacheDir = File(context.dataDir, Constants.CACHE_DIR)
cacheDir.mkdirs()
val bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
val disabled = File(cacheDir, "${Constants.MAIN_SCRIPT_FILE}.disabled")
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
settingsFile.parentFile?.mkdirs()
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
val currentlyDisabled = LogBoxUtils.isBundleInjectionDisabled(context)
val newState = !currentlyDisabled
if (bundle.exists() && disabled.exists()) {
bundle.delete()
}
if (newState) {
// Disable injection
if (disabled.exists()) {
disabled.delete()
}
if (bundle.exists()) {
bundle.renameTo(disabled)
} else {
disabled.writeText("")
}
settings.put("bundleInjectionDisabled", true)
Toast.makeText(context, "Bundle injection disabled", Toast.LENGTH_SHORT).show()
} else {
// Enable injection
if (disabled.exists()) {
disabled.renameTo(bundle)
} else if (!bundle.exists()) {
bundle.writeText("")
}
settings.put("bundleInjectionDisabled", false)
Toast.makeText(context, "Bundle injection enabled", Toast.LENGTH_SHORT).show()
}
settingsFile.writeText(settings.toString())
} catch (e: Exception) {
Log.e("Error toggling bundle injection: ${e.message}")
showError(context, "Failed to toggle bundle injection", e.message)
}
}
fun refetchBundle(context: Context) {
try {
val pyoncordDir = LogBoxUtils.getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js")
val backupFile = File(pyoncordDir, "bundle.js.backup")
if (bundleFile.exists()) {
backupFile.delete()
bundleFile.renameTo(backupFile)
Log.e("Bundle moved to backup")
}
reloadApp()
} catch (e: Exception) {
Log.e("Error refetching bundle: ${e.message}")
showError(context, "Failed to refetch bundle", e.message)
}
}
fun clearCacheAndReset(context: Context) {
scope.launch(Dispatchers.IO) {
try {
val pyoncordDir = LogBoxUtils.getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js")
val configFile = File(pyoncordDir, "loader.json")
bundleFile.delete()
if (configFile.exists()) {
val config = JSONObject(configFile.readText())
val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
customLoadUrl.put("enabled", false)
customLoadUrl.put("url", "http://localhost:4040/gooncord.js")
config.put("customLoadUrl", customLoadUrl)
configFile.writeText(config.toString())
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "Cache cleared successfully", Toast.LENGTH_SHORT).show()
reloadApp()
}
} catch (e: Exception) {
Log.e("Error clearing cache: ${e.message}")
withContext(Dispatchers.Main) {
Toast.makeText(context, "Failed to clear cache", Toast.LENGTH_SHORT).show()
}
}
}
}
fun setCustomBundleURL(context: Context, url: String, enabled: Boolean) {
try {
val pyoncordDir = LogBoxUtils.getPyoncordDirectory(context)
val configFile = File(pyoncordDir, "loader.json")
val config = if (configFile.exists()) {
JSONObject(configFile.readText())
} else {
JSONObject().apply {
put("loadReactDevTools", false)
}
}
val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
customLoadUrl.put("enabled", enabled)
customLoadUrl.put("url", url)
config.put("customLoadUrl", customLoadUrl)
configFile.writeText(config.toString())
if (enabled) {
File(pyoncordDir, "bundle.js").delete()
Toast.makeText(context, "Custom bundle enabled", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Custom bundle disabled", Toast.LENGTH_SHORT).show()
}
reloadApp()
} catch (e: Exception) {
Log.e("Error setting custom bundle URL: ${e.message}")
showError(context, "Failed to save configuration", e.message)
}
}
fun deleteDevtools(context: Context) {
try {
val devtoolsFile = File(LogBoxUtils.getPyoncordDirectory(context), "preloads/reactDevtools.js")
if (devtoolsFile.exists()) {
if (devtoolsFile.delete()) {
Toast.makeText(context, "Devtools deleted", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Failed to delete Devtools", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(context, "Devtools not found", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.e("Error deleting Devtools: ${e.message}")
showError(context, "Failed to delete Devtools", e.message)
}
}
fun showError(context: Context, title: String, message: String?) {
LogBoxMenu.showErrorDialog(context, title, message)
}
}

View file

@ -1,257 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.animation.*
import android.view.animation.DecelerateInterpolator
object LogBoxComponents {
fun createM3Button(context: Context, text: String, colors: M3Colors, onClick: () -> Unit): View {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
background = LogBoxUtils.createM3Background(context, colors.primaryContainer, 20f)
setPadding(
LogBoxUtils.dpToPx(context, 24),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 24),
LogBoxUtils.dpToPx(context, 10)
)
clipToOutline = true
isClickable = true
isFocusable = true
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
LogBoxUtils.dpToPx(context, 40)
).apply {
setMargins(0, LogBoxUtils.dpToPx(context, 8), 0, 0)
}
layoutParams = params
addView(TextView(context).apply {
this.text = text
setTextColor(colors.onPrimaryContainer)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
gravity = Gravity.CENTER
})
val rippleDrawable = android.graphics.drawable.RippleDrawable(
ColorStateList.valueOf(Color.argb(30, 255, 255, 255)),
LogBoxUtils.createM3Background(context, colors.primaryContainer, 20f),
null
)
background = rippleDrawable
setOnClickListener { onClick() }
}
}
fun createTitle(context: Context, colors: M3Colors, text: String, center: Boolean = false): TextView {
return TextView(context).apply {
this.text = text
textSize = if (center) 20f else 22f
setTextColor(colors.onSurface)
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, if (center) 12 else 16))
gravity = if (center) Gravity.CENTER else Gravity.START
}
}
fun createSubtitle(context: Context, colors: M3Colors, text: String): TextView {
return TextView(context).apply {
this.text = text
textSize = 14f
setTextColor(colors.onSurfaceVariant)
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
gravity = Gravity.CENTER
}
}
fun createMenuContainer(context: Context, colors: M3Colors, padding: Int = 24): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(
LogBoxUtils.dpToPx(context, padding),
LogBoxUtils.dpToPx(context, padding),
LogBoxUtils.dpToPx(context, padding),
LogBoxUtils.dpToPx(context, padding)
)
background = LogBoxUtils.createM3Background(context, colors.surface, if (padding == 24) 28f else 24f)
}
}
fun createM3Switch(context: Context, colors: M3Colors, isChecked: Boolean): View {
val switchView = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
tag = isChecked
layoutParams = LinearLayout.LayoutParams(
LogBoxUtils.dpToPx(context, 52),
LogBoxUtils.dpToPx(context, 32)
)
}
updateSwitchAppearance(context, switchView, colors, isChecked)
return switchView
}
fun updateSwitchAppearance(context: Context, switchView: View, colors: M3Colors, isChecked: Boolean) {
val trackColor = if (isChecked) colors.primary else colors.surfaceVariant
val thumbColor = if (isChecked) colors.onPrimary else colors.onSurfaceVariant
val currentTrackColor = if (switchView.background is GradientDrawable) {
(switchView.background as GradientDrawable).color?.defaultColor
?: (if (!isChecked) colors.primary else colors.surfaceVariant)
} else {
if (!isChecked) colors.primary else colors.surfaceVariant
}
val trackColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentTrackColor, trackColor)
trackColorAnimator.duration = 250
trackColorAnimator.interpolator = DecelerateInterpolator()
trackColorAnimator.addUpdateListener { animator ->
val animatedColor = animator.animatedValue as Int
val track = GradientDrawable().apply {
setColor(animatedColor)
setCornerRadius(LogBoxUtils.dpToPx(context, 16).toFloat())
}
switchView.background = track
}
if (switchView is LinearLayout) {
val thumb = if (switchView.childCount > 0) {
switchView.getChildAt(0)
} else {
View(context).apply {
val size = LogBoxUtils.dpToPx(context, 24)
layoutParams = LinearLayout.LayoutParams(size, size).apply {
marginStart = LogBoxUtils.dpToPx(context, 4)
marginEnd = LogBoxUtils.dpToPx(context, 24)
}
switchView.addView(this)
}
}
val currentParams = thumb.layoutParams as LinearLayout.LayoutParams
val targetMarginStart = if (isChecked) LogBoxUtils.dpToPx(context, 24) else LogBoxUtils.dpToPx(context, 4)
val marginAnimator = ValueAnimator.ofInt(currentParams.marginStart, targetMarginStart)
marginAnimator.duration = 250
marginAnimator.interpolator = DecelerateInterpolator()
marginAnimator.addUpdateListener { animator ->
val animatedMargin = animator.animatedValue as Int
val params = thumb.layoutParams as LinearLayout.LayoutParams
params.marginStart = animatedMargin
params.marginEnd = LogBoxUtils.dpToPx(context, 28) - animatedMargin
thumb.layoutParams = params
}
val currentThumbColor = if (thumb.background is GradientDrawable) {
(thumb.background as GradientDrawable).color?.defaultColor
?: (if (!isChecked) colors.onPrimary else colors.onSurfaceVariant)
} else {
if (!isChecked) colors.onPrimary else colors.onSurfaceVariant
}
val thumbColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentThumbColor, thumbColor)
thumbColorAnimator.duration = 250
thumbColorAnimator.interpolator = DecelerateInterpolator()
thumbColorAnimator.addUpdateListener { animator ->
val animatedColor = animator.animatedValue as Int
thumb.background = GradientDrawable().apply {
setColor(animatedColor)
shape = GradientDrawable.OVAL
}
}
val animatorSet = AnimatorSet()
animatorSet.playTogether(trackColorAnimator, marginAnimator, thumbColorAnimator)
animatorSet.start()
}
}
fun createAppearanceSelector(context: Context, colors: M3Colors): View {
val currentMode = LogBoxUtils.getAppearanceMode(context)
val options = listOf("System", "Light", "Dark")
val keys = listOf("system", "light", "dark")
val wrapper = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
layoutParams = params
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 8))
}
val label = TextView(context).apply {
text = "Appearance"
textSize = 14f
setTextColor(colors.onSurface)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 8))
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
}
wrapper.addView(label)
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
LogBoxUtils.dpToPx(context, 40)
)
layoutParams = params
}
options.forEachIndexed { idx, title ->
val key = keys[idx]
val seg = TextView(context).apply {
text = title
gravity = Gravity.CENTER
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
val segParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
if (idx > 0) segParams.setMargins(LogBoxUtils.dpToPx(context, 8), 0, 0, 0)
layoutParams = segParams
val isSelected = currentMode == key
background = LogBoxUtils.createM3Background(
context,
if (isSelected) colors.primaryContainer else colors.surfaceVariant,
12f
)
setTextColor(if (isSelected) colors.onPrimaryContainer else colors.onSurface)
setPadding(0, 0, 0, 0)
setOnClickListener {
LogBoxUtils.setAppearanceMode(context, key)
for (i in 0 until row.childCount) {
val child = row.getChildAt(i) as TextView
val sel = keys[i] == key
child.background = LogBoxUtils.createM3Background(
context,
if (sel) colors.primaryContainer else colors.surfaceVariant,
12f
)
child.setTextColor(if (sel) colors.onPrimaryContainer else colors.onSurface)
}
}
}
row.addView(seg)
}
wrapper.addView(row)
return wrapper
}
}

View file

@ -1,35 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.graphics.Color
data class M3Colors(
val surface: Int,
val surfaceVariant: Int,
val onSurface: Int,
val onSurfaceVariant: Int,
val primary: Int,
val onPrimary: Int,
val primaryContainer: Int,
val onPrimaryContainer: Int,
val secondaryContainer: Int,
val onSecondaryContainer: Int,
val error: Int,
val onError: Int
)
object LogBoxConstants {
const val TAG = "LogBoxModule"
val FLAVOR_COLORS = mapOf(
"blue" to Pair("#0D47A1", "#82B1FF"),
"green" to Pair("#1B5E20", "#A5D6A7"),
"mocha" to Pair("#3E2723", "#BCAAA4"),
"vanilla" to Pair("#F9A825", "#FFF59D"),
"purple" to Pair("#6A1B9A", "#E1BEE7"),
"amber" to Pair("#FF6F00", "#FFE0B2"),
"teal" to Pair("#004D40", "#80CBC4")
)
val APPEARANCE_MODES = listOf("system", "light", "dark")
val FLAVORS = listOf("blue", "green", "mocha", "vanilla", "purple", "amber", "teal")
}

View file

@ -1,74 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.app.AlertDialog
import android.content.Context
import android.graphics.Color
import android.view.ViewGroup
import android.widget.LinearLayout
import GoonXposed.xposed.Utils.Log
object LogBoxDialogs {
var currentMenuContainer: LinearLayout? = null
fun createDialog(context: Context, container: LinearLayout): AlertDialog {
val dialog = AlertDialog.Builder(context)
.setView(container)
.create()
dialog.window?.setBackgroundDrawable(
LogBoxUtils.createM3Background(context, Color.TRANSPARENT, 28f)
)
constrainContainerWidth(context, container)
return dialog
}
fun constrainContainerWidth(context: Context, container: LinearLayout) {
try {
val maxW = (context.resources.displayMetrics.widthPixels * 0.9).toInt()
val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
container.layoutParams = lp
try {
container.minimumWidth = maxW
} catch (_: Exception) {
}
} catch (_: Exception) {
}
}
fun setDialogWindowWidth(dialog: AlertDialog, context: Context) {
try {
val screenW = context.resources.displayMetrics.widthPixels
val maxByPercent = (screenW * 0.9).toInt()
val maxByDp = LogBoxUtils.dpToPx(context, 420)
val targetW = if (maxByPercent < maxByDp) maxByPercent else maxByDp
dialog.window?.setLayout(targetW, ViewGroup.LayoutParams.WRAP_CONTENT)
try {
currentMenuContainer?.let { container ->
val lp = container.layoutParams ?: LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
container.layoutParams = lp
try {
container.minimumWidth = targetW
} catch (_: Exception) {
}
container.post {
try {
container.requestLayout()
container.invalidate()
} catch (_: Exception) {
}
}
}
} catch (_: Exception) {
}
} catch (_: Exception) {
}
}
}

View file

@ -1,795 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.app.AlertDialog
import android.content.Context
import android.graphics.Color
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.*
import android.animation.*
import android.graphics.drawable.GradientDrawable
import android.text.InputType
import android.content.res.ColorStateList
import android.view.animation.DecelerateInterpolator
import org.json.JSONObject
import java.io.File
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.Utils
object LogBoxMenu {
private var currentMenuContainer: LinearLayout? = null
fun showRecoveryMenu(context: Context) {
Log.e("showRecoveryMenu called with context: $context")
try {
val colors = LogBoxTheme.getM3Colors(context)
lateinit var dialog: AlertDialog
val container = createMenuContainer(context, colors)
// Title
container.addView(createTitle(context, colors, "GoonXposed Recovery", center = true))
// Subtitle
container.addView(createSubtitle(context, colors, "Select an option to continue"))
// Safe Mode Button
val safeModeText = if (LogBoxUtils.isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode"
container.addView(createM3Button(context, safeModeText, colors) {
dialog.dismiss()
handleMenuSelection(context, 0)
})
// Load Custom Bundle Button
container.addView(createM3Button(context, "Load Custom Bundle", colors) {
dialog.dismiss()
handleMenuSelection(context, 2)
})
// Reload App Button
container.addView(createM3Button(context, "Reload App", colors) {
dialog.dismiss()
handleMenuSelection(context, 3)
})
// Options Button
val optionsBtn = createM3Button(context, "Options", colors) {
dialog.dismiss()
handleMenuSelection(context, 5)
}
(optionsBtn.layoutParams as? LinearLayout.LayoutParams)?.let { lp ->
lp.setMargins(lp.leftMargin, LogBoxUtils.dpToPx(context, 16), lp.rightMargin, lp.bottomMargin)
optionsBtn.layoutParams = lp
}
container.addView(optionsBtn)
dialog = createDialog(context, container)
currentMenuContainer = container
dialog.show()
setDialogWindowWidth(dialog, context)
Log.e("Recovery menu shown successfully")
} catch (e: Exception) {
Log.e("Error showing recovery menu: ${e.message}", e)
throw e
}
}
fun showOptionsMenu(context: Context) {
val colors = LogBoxTheme.getM3Colors(context)
lateinit var dialog: AlertDialog
val container = createMenuContainer(context, colors, 20)
container.addView(createTitle(context, colors, "Options", center = true))
// Themes button -> opens Themes submenu
container.addView(createM3Button(context, "Themes", colors) {
dialog.dismiss()
showThemesMenu(context)
})
// Disable/Enable bundle injection
val injectionDisabled = LogBoxUtils.isBundleInjectionDisabled(context)
val injectionText = if (injectionDisabled) "Enable Bundle Injection" else "Disable Bundle Injection"
container.addView(createM3Button(context, injectionText, colors) {
dialog.dismiss()
LogBoxActions.toggleBundleInjection(context)
Utils.reloadApp()
})
container.addView(createM3Button(context, "Refetch Bundle", colors) {
dialog.dismiss()
showConfirmAction(context, "Refetch Bundle", "This will download the latest bundle from Github.") {
LogBoxActions.refetchBundle(context)
}
})
container.addView(createM3Button(context, "Clear Cache & Reset", colors) {
dialog.dismiss()
showConfirmAction(
context,
"Clear Cache & Reset",
"This will clear all cached bundles and reset to default settings."
) {
LogBoxActions.clearCacheAndReset(context)
}
})
container.addView(createM3Button(context, "Delete Devtools", colors) {
dialog.dismiss()
showConfirmAction(
context,
"Delete Devtools",
"This will delete the reactDevtools.js preload file if it exists."
) {
LogBoxActions.deleteDevtools(context)
}
})
container.addView(createM3Button(context, "Back", colors) {
dialog.dismiss()
showRecoveryMenu(context)
})
dialog = createDialog(context, container)
currentMenuContainer = container
dialog.show()
setDialogWindowWidth(dialog, context)
}
fun showThemesMenu(context: Context) {
val colors = LogBoxTheme.getM3Colors(context)
lateinit var dialog: AlertDialog
val container = createMenuContainer(context, colors, 20)
container.addView(createTitle(context, colors, "Themes", center = true))
// Segmented appearance selector
container.addView(createAppearanceSelector(context, colors))
// Flavor selection
container.addView(createM3Button(context, "Menu Color Flavor", colors) {
dialog.dismiss()
showFlavorSelection(context)
})
// Back button (return to Options)
container.addView(createM3Button(context, "Back", colors) {
dialog.dismiss()
showOptionsMenu(context)
})
dialog = createDialog(context, container)
currentMenuContainer = container
dialog.show()
setDialogWindowWidth(dialog, context)
}
fun showFlavorSelection(context: Context) {
val colors = LogBoxTheme.getM3Colors(context)
lateinit var dialog: AlertDialog
val container = createMenuContainer(context, colors, 20)
container.addView(createTitle(context, colors, "Select Menu Flavor", center = true))
LogBoxConstants.FLAVORS.forEach { flavor ->
container.addView(createM3Button(context, flavor.replaceFirstChar { it.uppercase() }, colors) {
try {
dialog.dismiss()
} catch (_: Exception) {
}
LogBoxUtils.saveMenuFlavor(context, flavor)
showThemesMenu(context)
})
}
dialog = createDialog(context, container)
currentMenuContainer = container
dialog.show()
setDialogWindowWidth(dialog, context)
}
fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) {
val colors = LogBoxTheme.getM3Colors(context)
val container = createMenuContainer(context, colors)
container.addView(createTitle(context, colors, title))
val messageView = TextView(context).apply {
text = message
textSize = 14f
setTextColor(colors.onSurfaceVariant)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
lineHeight = LogBoxUtils.dpToPx(context, 20)
}
}
container.addView(messageView)
val buttonContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.END
}
val cancelButton = TextView(context).apply {
text = "Cancel"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val confirmButton = TextView(context).apply {
text = "Confirm"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val dialog = createDialog(context, container)
cancelButton.setOnClickListener { dialog.dismiss() }
confirmButton.setOnClickListener {
dialog.dismiss()
action()
}
buttonContainer.addView(cancelButton)
buttonContainer.addView(confirmButton)
container.addView(buttonContainer)
currentMenuContainer = container
dialog.show()
setDialogWindowWidth(dialog, context)
}
fun showCustomBundleDialog(context: Context) {
val colors = LogBoxTheme.getM3Colors(context)
val filesDir = LogBoxUtils.getPyoncordDirectory(context)
val configFile = File(filesDir, "loader.json")
var currentUrl: String? = null
var isEnabled = false
if (configFile.exists()) {
try {
val json = JSONObject(configFile.readText())
val customLoadUrl = json.optJSONObject("customLoadUrl")
if (customLoadUrl != null) {
isEnabled = customLoadUrl.optBoolean("enabled", false)
currentUrl = customLoadUrl.optString("url", "")
}
} catch (_: Exception) {
}
}
val container = createMenuContainer(context, colors)
container.addView(createTitle(context, colors, "Custom Bundle URL"))
// Toggle switch container
val toggleContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, 0, LogBoxUtils.dpToPx(context, 16))
}
layoutParams = params
}
val toggleLabel = TextView(context).apply {
text = "Enable Custom URL"
textSize = 14f
setTextColor(colors.onSurface)
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
layoutParams = LinearLayout.LayoutParams(
0,
ViewGroup.LayoutParams.WRAP_CONTENT,
1f
)
}
val toggleSwitch = createM3Switch(context, colors, isEnabled)
toggleContainer.addView(toggleLabel)
toggleContainer.addView(toggleSwitch)
container.addView(toggleContainer)
val urlInput = EditText(context).apply {
hint = "http://localhost:4040/shiggycord.js"
setTextColor(colors.onSurface)
setHintTextColor(colors.onSurfaceVariant)
background = LogBoxUtils.createM3Background(context, colors.surfaceVariant, 12f)
inputType = InputType.TYPE_TEXT_VARIATION_URI
setText(currentUrl ?: "")
textSize = 14f
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 12),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 12)
)
alpha = if (isEnabled) 1f else 0.5f
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
}
layoutParams = params
}
container.addView(urlInput)
toggleSwitch.setOnClickListener {
val currentState = toggleSwitch.tag as Boolean
val newState = !currentState
toggleSwitch.tag = newState
updateSwitchAppearance(context, toggleSwitch, colors, newState)
urlInput.animate()
.alpha(if (newState) 1f else 0.5f)
.setDuration(250)
.withEndAction {
urlInput.isEnabled = newState
}
.start()
}
val buttonContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.END
}
val cancelButton = TextView(context).apply {
text = "Cancel"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val saveButton = TextView(context).apply {
text = "Save"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val dialog = createDialog(context, container)
cancelButton.setOnClickListener { dialog.dismiss() }
saveButton.setOnClickListener {
try {
val url = urlInput.text?.toString()?.trim() ?: ""
val enabled = toggleSwitch.tag as Boolean
if (enabled && url.isNotEmpty()) {
LogBoxActions.setCustomBundleURL(context, url, true)
dialog.dismiss()
} else if (!enabled) {
LogBoxActions.setCustomBundleURL(
context,
url.ifEmpty { "http://localhost:4040/shiggycord.js" },
false
)
dialog.dismiss()
} else {
Toast.makeText(context, "Please enter a valid URL", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
val err = e.message ?: "unknown error"
Toast.makeText(context, "Failed to set custom bundle: $err", Toast.LENGTH_LONG).show()
}
}
buttonContainer.addView(cancelButton)
buttonContainer.addView(saveButton)
container.addView(buttonContainer)
currentMenuContainer = container
dialog.show()
setDialogWindowWidth(dialog, context)
}
fun showErrorDialog(context: Context, title: String, message: String?) {
val colors = LogBoxTheme.getM3Colors(context)
val container = createMenuContainer(context, colors)
val titleView = TextView(context).apply {
text = title
textSize = 20f
setTextColor(colors.error)
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 16))
}
container.addView(titleView)
val messageView = TextView(context).apply {
text = message ?: "An unknown error occurred"
textSize = 14f
setTextColor(colors.onSurfaceVariant)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
lineHeight = LogBoxUtils.dpToPx(context, 20)
}
}
container.addView(messageView)
val okButton = TextView(context).apply {
text = "OK"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
gravity = Gravity.END
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val dialog = createDialog(context, container)
okButton.setOnClickListener { dialog.dismiss() }
container.addView(okButton)
currentMenuContainer = container
dialog.show()
setDialogWindowWidth(dialog, context)
}
// Helper functions
private fun createMenuContainer(context: Context, colors: M3Colors, padding: Int = 24): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(
LogBoxUtils.dpToPx(context, padding),
LogBoxUtils.dpToPx(context, padding),
LogBoxUtils.dpToPx(context, padding),
LogBoxUtils.dpToPx(context, padding)
)
background = LogBoxUtils.createM3Background(context, colors.surface, if (padding == 24) 28f else 24f)
}
}
private fun createTitle(context: Context, colors: M3Colors, text: String, center: Boolean = false): TextView {
return TextView(context).apply {
this.text = text
textSize = if (center) 20f else 22f
setTextColor(colors.onSurface)
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, if (center) 12 else 16))
gravity = if (center) Gravity.CENTER else Gravity.START
}
}
private fun createSubtitle(context: Context, colors: M3Colors, text: String): TextView {
return TextView(context).apply {
this.text = text
textSize = 14f
setTextColor(colors.onSurfaceVariant)
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
gravity = Gravity.CENTER
}
}
fun createM3Button(context: Context, text: String, colors: M3Colors, onClick: () -> Unit): View {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
background = LogBoxUtils.createM3Background(context, colors.primaryContainer, 20f)
setPadding(
LogBoxUtils.dpToPx(context, 24),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 24),
LogBoxUtils.dpToPx(context, 10)
)
clipToOutline = true
isClickable = true
isFocusable = true
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
LogBoxUtils.dpToPx(context, 40)
).apply {
setMargins(0, LogBoxUtils.dpToPx(context, 8), 0, 0)
}
layoutParams = params
addView(TextView(context).apply {
this.text = text
setTextColor(colors.onPrimaryContainer)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
gravity = Gravity.CENTER
})
val rippleDrawable = android.graphics.drawable.RippleDrawable(
ColorStateList.valueOf(Color.argb(30, 255, 255, 255)),
LogBoxUtils.createM3Background(context, colors.primaryContainer, 20f),
null
)
background = rippleDrawable
setOnClickListener { onClick() }
}
}
private fun createAppearanceSelector(context: Context, colors: M3Colors): View {
val currentMode = LogBoxUtils.getAppearanceMode(context)
val options = listOf("System", "Light", "Dark")
val keys = listOf("system", "light", "dark")
val wrapper = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
layoutParams = params
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 8))
}
val label = TextView(context).apply {
text = "Appearance"
textSize = 14f
setTextColor(colors.onSurface)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 8))
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
}
wrapper.addView(label)
val row = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
val params = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
LogBoxUtils.dpToPx(context, 40)
)
layoutParams = params
}
options.forEachIndexed { idx, title ->
val key = keys[idx]
val seg = TextView(context).apply {
text = title
gravity = Gravity.CENTER
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
val segParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)
if (idx > 0) segParams.setMargins(LogBoxUtils.dpToPx(context, 8), 0, 0, 0)
layoutParams = segParams
val isSelected = currentMode == key
background = LogBoxUtils.createM3Background(
context,
if (isSelected) colors.primaryContainer else colors.surfaceVariant,
12f
)
setTextColor(if (isSelected) colors.onPrimaryContainer else colors.onSurface)
setPadding(0, 0, 0, 0)
setOnClickListener {
LogBoxUtils.setAppearanceMode(context, key)
for (i in 0 until row.childCount) {
val child = row.getChildAt(i) as TextView
val sel = keys[i] == key
child.background = LogBoxUtils.createM3Background(
context,
if (sel) colors.primaryContainer else colors.surfaceVariant,
12f
)
child.setTextColor(if (sel) colors.onPrimaryContainer else colors.onSurface)
}
}
}
row.addView(seg)
}
wrapper.addView(row)
return wrapper
}
private fun createM3Switch(context: Context, colors: M3Colors, isChecked: Boolean): View {
val switchView = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
tag = isChecked
layoutParams = LinearLayout.LayoutParams(
LogBoxUtils.dpToPx(context, 52),
LogBoxUtils.dpToPx(context, 32)
)
}
updateSwitchAppearance(context, switchView, colors, isChecked)
return switchView
}
private fun updateSwitchAppearance(context: Context, switchView: View, colors: M3Colors, isChecked: Boolean) {
val trackColor = if (isChecked) colors.primary else colors.surfaceVariant
val thumbColor = if (isChecked) colors.onPrimary else colors.onSurfaceVariant
val currentTrackColor = if (switchView.background is GradientDrawable) {
(switchView.background as GradientDrawable).color?.defaultColor
?: (if (!isChecked) colors.primary else colors.surfaceVariant)
} else {
if (!isChecked) colors.primary else colors.surfaceVariant
}
val trackColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentTrackColor, trackColor)
trackColorAnimator.duration = 250
trackColorAnimator.interpolator = DecelerateInterpolator()
trackColorAnimator.addUpdateListener { animator ->
val animatedColor = animator.animatedValue as Int
val track = GradientDrawable().apply {
setColor(animatedColor)
setCornerRadius(LogBoxUtils.dpToPx(context, 16).toFloat())
}
switchView.background = track
}
if (switchView is LinearLayout) {
val thumb = if (switchView.childCount > 0) {
switchView.getChildAt(0)
} else {
View(context).apply {
val size = LogBoxUtils.dpToPx(context, 24)
layoutParams = LinearLayout.LayoutParams(size, size).apply {
marginStart = LogBoxUtils.dpToPx(context, 4)
marginEnd = LogBoxUtils.dpToPx(context, 24)
}
switchView.addView(this)
}
}
val currentParams = thumb.layoutParams as LinearLayout.LayoutParams
val targetMarginStart = if (isChecked) LogBoxUtils.dpToPx(context, 24) else LogBoxUtils.dpToPx(context, 4)
val marginAnimator = ValueAnimator.ofInt(currentParams.marginStart, targetMarginStart)
marginAnimator.duration = 250
marginAnimator.interpolator = DecelerateInterpolator()
marginAnimator.addUpdateListener { animator ->
val animatedMargin = animator.animatedValue as Int
val params = thumb.layoutParams as LinearLayout.LayoutParams
params.marginStart = animatedMargin
params.marginEnd = LogBoxUtils.dpToPx(context, 28) - animatedMargin
thumb.layoutParams = params
}
val currentThumbColor = if (thumb.background is GradientDrawable) {
(thumb.background as GradientDrawable).color?.defaultColor
?: (if (!isChecked) colors.onPrimary else colors.onSurfaceVariant)
} else {
if (!isChecked) colors.onPrimary else colors.onSurfaceVariant
}
val thumbColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentThumbColor, thumbColor)
thumbColorAnimator.duration = 250
thumbColorAnimator.interpolator = DecelerateInterpolator()
thumbColorAnimator.addUpdateListener { animator ->
val animatedColor = animator.animatedValue as Int
thumb.background = GradientDrawable().apply {
setColor(animatedColor)
shape = GradientDrawable.OVAL
}
}
val animatorSet = AnimatorSet()
animatorSet.playTogether(trackColorAnimator, marginAnimator, thumbColorAnimator)
animatorSet.start()
}
}
private fun createDialog(context: Context, container: LinearLayout): AlertDialog {
val dialog = AlertDialog.Builder(context)
.setView(container)
.create()
dialog.window?.setBackgroundDrawable(
LogBoxUtils.createM3Background(context, Color.TRANSPARENT, 28f)
)
constrainContainerWidth(context, container)
return dialog
}
private fun constrainContainerWidth(context: Context, container: LinearLayout) {
try {
val maxW = (context.resources.displayMetrics.widthPixels * 0.9).toInt()
val lp = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
container.layoutParams = lp
try {
container.minimumWidth = maxW
} catch (_: Exception) {
}
} catch (_: Exception) {
}
}
private fun setDialogWindowWidth(dialog: AlertDialog, context: Context) {
try {
val screenW = context.resources.displayMetrics.widthPixels
val maxByPercent = (screenW * 0.9).toInt()
val maxByDp = LogBoxUtils.dpToPx(context, 420)
val targetW = if (maxByPercent < maxByDp) maxByPercent else maxByDp
dialog.window?.setLayout(targetW, ViewGroup.LayoutParams.WRAP_CONTENT)
try {
currentMenuContainer?.let { container ->
val lp = container.layoutParams ?: ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
lp.width = ViewGroup.LayoutParams.MATCH_PARENT
container.layoutParams = lp
try {
container.minimumWidth = targetW
} catch (_: Exception) {
}
container.post {
try {
container.requestLayout()
container.invalidate()
} catch (_: Exception) {
}
}
}
} catch (_: Exception) {
}
} catch (_: Exception) {
}
}
private fun handleMenuSelection(context: Context, index: Int) {
when (index) {
0 -> LogBoxActions.toggleSafeMode(context)
1 -> showConfirmAction(
context, "Refetch Bundle",
"This will download the latest bundle from Github."
) { LogBoxActions.refetchBundle(context) }
2 -> showCustomBundleDialog(context)
3 -> Utils.reloadApp()
4 -> showConfirmAction(
context, "Clear Cache & Reset",
"This will clear all cached bundles and reset to default settings."
) { LogBoxActions.clearCacheAndReset(context) }
// Options submenu
5 -> showOptionsMenu(context)
}
}
}

View file

@ -1,205 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.Utils.Companion.reloadApp
import android.content.Context
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XC_MethodReplacement
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import kotlinx.coroutines.*
object LogBoxModule : Module() {
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
var contextForMenu: Context? = null
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
this@LogBoxModule.packageParam = packageParam
try {
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
val getUseDeveloperSupportMethod =
dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
// 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
}
override fun onContext(context: Context) {
try {
Log.e("onContext called with context: $context")
contextForMenu = context
val possibleClasses = listOf(
"com.facebook.react.devsupport.BridgeDevSupportManager",
"com.facebook.react.devsupport.BridgelessDevSupportManager",
"com.facebook.react.devsupport.DevSupportManagerImpl",
"com.facebook.react.devsupport.DevSupportManagerBase",
"com.facebook.react.devsupport.DefaultDevSupportManager"
)
var foundAny = false
possibleClasses.forEach { className ->
try {
val clazz = packageParam.classLoader.loadClass(className)
Log.e("Found class: $className")
hookDevSupportManager(clazz, context)
foundAny = true
} catch (e: Exception) {
Log.e("Class not found: $className - ${e.message}")
}
}
if (!foundAny) {
tryFindDevSupportClasses(context)
}
} catch (e: Exception) {
// Handle exception
}
}
private fun tryFindDevSupportClasses(context: Context) {
try {
val dexFile = packageParam.classLoader.javaClass.getDeclaredField("pathList")
dexFile.isAccessible = true
Log.e("Searching for DevSupport classes in classloader...")
} catch (e: Exception) {
Log.e("Could not search for classes: ${e.message}")
}
}
private fun hookDevSupportManager(clazz: Class<*>, context: Context) {
Log.e("Attempting to hook ${clazz.name}")
Log.e("Available methods in ${clazz.simpleName}:")
clazz.methods.forEach { method ->
if (method.name.contains("Dev") || method.name.contains("Reload") || method.name.contains("Options")) {
Log.e(" - ${method.name}")
}
}
try {
try {
val handleReloadJSMethod = clazz.methods.firstOrNull { it.name == "handleReloadJS" }
if (handleReloadJSMethod != null) {
XposedBridge.hookMethod(handleReloadJSMethod, object : XC_MethodReplacement() {
override fun replaceHookedMethod(param: MethodHookParam): Any? {
Log.e("handleReloadJS called - reloading app")
reloadApp()
return null
}
})
}
} catch (e: Exception) {
Log.e("Failed to hook handleReloadJS: ${e.message}")
}
try {
val showDevOptionsDialogMethod = clazz.methods.firstOrNull { it.name == "showDevOptionsDialog" }
if (showDevOptionsDialogMethod != null) {
XposedBridge.hookMethod(showDevOptionsDialogMethod, object : XC_MethodReplacement() {
override fun replaceHookedMethod(param: MethodHookParam): Any? {
try {
var activityContext: Context? = null
try {
activityContext = getContextFromDevSupport(clazz, param.thisObject)
if (activityContext != null) {
Log.e("Successfully got context from DevSupport")
}
} catch (e: Exception) {
Log.e("Failed to get context from DevSupport (non-fatal): ${e.message}")
}
val finalContext = activityContext ?: contextForMenu ?: context
Log.e("Using context: $finalContext (type: ${finalContext.javaClass.name})")
LogBoxNavigation.showRecoveryMenu(finalContext)
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
})
}
} catch (e: Exception) {
// Handle exception
}
} catch (e: Exception) {
// Handle exception
}
}
private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? {
if (instance == null) {
Log.e("getContextFromDevSupport: instance is null")
return null
}
return try {
val helpers = listOf(
"mReactInstanceDevHelper",
"reactInstanceDevHelper",
"mReactInstanceManager",
"mApplicationContext"
)
for (helperName in helpers) {
try {
Log.e("Trying field: $helperName")
val helperField = XposedHelpers.findFieldIfExists(clazz, helperName)
if (helperField == null) {
Log.e("Field $helperName not found, skipping")
continue
}
val helper = helperField.get(instance)
if (helper == null) {
Log.e("Field $helperName is null, skipping")
continue
}
if (helper is Context) {
Log.e("Field $helperName is a Context, returning it")
return helper
}
val getCurrentActivityMethod = helper.javaClass.methods.firstOrNull {
it.name == "getCurrentActivity"
}
if (getCurrentActivityMethod != null) {
val ctx = getCurrentActivityMethod.invoke(helper) as? Context
if (ctx != null) {
Log.e("Got context from $helperName.getCurrentActivity()")
return ctx
}
}
} catch (e: Exception) {
Log.e("Error trying $helperName: ${e.message}")
}
}
Log.e("Could not get context from DevSupport object using any method")
null
} catch (e: Exception) {
Log.e("Failed to get context (outer catch): ${e.message}")
null
}
}
}

View file

@ -1,9 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.content.Context
object LogBoxNavigation {
fun showRecoveryMenu(context: Context) {
LogBoxMenu.showRecoveryMenu(context)
}
}

View file

@ -1,95 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.content.Context
import android.graphics.Color
import org.json.JSONObject
import java.io.File
import GoonXposed.xposed.Utils.Log
object LogBoxTheme {
fun isDarkMode(context: Context): Boolean {
try {
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
if (settingsFile.exists()) {
val json = JSONObject(settingsFile.readText())
val mode = json.optString("appearanceMode", "")
if (mode == "light") return false
if (mode == "dark") return true
}
} catch (e: Exception) {
Log.e("Error reading LogBox appearance settings: ${e.message}")
}
return (context.resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
}
fun getM3Colors(context: Context): M3Colors {
val isDark = isDarkMode(context)
val base = if (isDark) {
M3Colors(
surface = Color.parseColor("#1C1B1F"),
surfaceVariant = Color.parseColor("#49454F"),
onSurface = Color.parseColor("#E6E1E5"),
onSurfaceVariant = Color.parseColor("#CAC4D0"),
primary = Color.parseColor("#D0BCFF"),
onPrimary = Color.parseColor("#381E72"),
primaryContainer = Color.parseColor("#4F378B"),
onPrimaryContainer = Color.parseColor("#EADDFF"),
secondaryContainer = Color.parseColor("#4A4458"),
onSecondaryContainer = Color.parseColor("#E8DEF8"),
error = Color.parseColor("#F2B8B5"),
onError = Color.parseColor("#601410")
)
} else {
M3Colors(
surface = Color.parseColor("#FFFBFE"),
surfaceVariant = Color.parseColor("#E7E0EC"),
onSurface = Color.parseColor("#1C1B1F"),
onSurfaceVariant = Color.parseColor("#49454F"),
primary = Color.parseColor("#6750A4"),
onPrimary = Color.parseColor("#FFFFFF"),
primaryContainer = Color.parseColor("#EADDFF"),
onPrimaryContainer = Color.parseColor("#21005D"),
secondaryContainer = Color.parseColor("#E8DEF8"),
onSecondaryContainer = Color.parseColor("#1D192B"),
error = Color.parseColor("#B3261E"),
onError = Color.parseColor("#FFFFFF")
)
}
var flavor = "mocha"
try {
val themeFile = File(context.filesDir, "logbox/LOGBOX_THEMES")
if (themeFile.exists()) {
val json = JSONObject(themeFile.readText())
val f = json.optString("menuFlavor", json.optString("flavor", json.optString("id", ""))).lowercase()
if (f.isNotEmpty()) flavor = f
}
if (flavor.isNotEmpty()) {
val pair = LogBoxConstants.FLAVOR_COLORS[flavor]
if (pair != null) {
val primary = Color.parseColor(pair.first)
val primaryContainer = Color.parseColor(pair.second)
val onPrimary = if (isDark) Color.WHITE else Color.WHITE
val onPrimaryContainer = if (isDark) Color.BLACK else Color.BLACK
return base.copy(
primary = primary,
primaryContainer = primaryContainer,
onPrimary = onPrimary,
onPrimaryContainer = onPrimaryContainer
)
}
}
} catch (e: Exception) {
Log.e("Error applying LogBox flavor: ${e.message}")
}
return base
}
}

View file

@ -1,121 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.content.Context
import android.util.TypedValue
import android.graphics.drawable.GradientDrawable
import android.graphics.Color
import android.content.res.ColorStateList
import android.view.View
import android.widget.Toast
import org.json.JSONObject
import java.io.File
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Utils.Log
object LogBoxUtils {
fun dpToPx(context: Context, dp: Int): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp.toFloat(),
context.resources.displayMetrics
).toInt()
}
fun createM3Background(context: Context, color: Int, cornerRadius: Float): GradientDrawable {
return GradientDrawable().apply {
setColor(color)
setCornerRadius(dpToPx(context, cornerRadius.toInt()).toFloat())
}
}
fun getPyoncordDirectory(context: Context): File {
val dir = File(context.filesDir, "pyoncord")
if (!dir.exists()) {
dir.mkdirs()
}
return dir
}
fun showM3Toast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
fun getAppearanceMode(context: Context): String {
return try {
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
if (!settingsFile.exists()) return "system"
val json = JSONObject(settingsFile.readText())
json.optString("appearanceMode", "system")
} catch (e: Exception) {
Log.e("Error reading appearance mode: ${e.message}")
"system"
}
}
fun setAppearanceMode(context: Context, mode: String) {
try {
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
settingsFile.parentFile?.mkdirs()
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
settings.put("appearanceMode", mode)
settingsFile.writeText(settings.toString())
} catch (e: Exception) {
LogBoxActions.showError(context, "Failed to set appearance", e.message)
}
}
fun saveMenuFlavor(context: Context, flavor: String) {
try {
val themeFile = File(context.filesDir, "logbox/LOGBOX_THEMES")
themeFile.parentFile?.mkdirs()
val themeJson = JSONObject().apply {
put("menuFlavor", flavor)
}
themeFile.writeText(themeJson.toString())
} catch (e: Exception) {
Log.e("Error saving menu flavor: ${e.message}")
LogBoxActions.showError(context, "Failed to save flavor", e.message)
}
}
fun isBundleInjectionDisabled(context: Context): Boolean {
return try {
val cacheDir = File(context.dataDir, Constants.CACHE_DIR)
val bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
val disabled = File(cacheDir, "${Constants.MAIN_SCRIPT_FILE}.disabled")
if (disabled.exists()) {
if (bundle.exists()) {
bundle.delete()
}
return true
}
if (bundle.exists()) return false
// If neither file exists, check settings
val settingsFile = File(context.filesDir, "logbox/LOGBOX_SETTINGS")
if (!settingsFile.exists()) return false
val json = JSONObject(settingsFile.readText())
json.optBoolean("bundleInjectionDisabled", false)
} catch (e: Exception) {
Log.e("Error reading injection setting: ${e.message}")
false
}
}
fun isSafeModeEnabled(context: Context): Boolean {
return try {
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
if (!settingsFile.exists()) return false
val json = JSONObject(settingsFile.readText())
json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false
} catch (e: Exception) {
Log.e("Error checking safe mode: ${e.message}")
false
}
}
}

View file

@ -1,57 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.app.AlertDialog
import android.content.Context
object OptionsMenu {
fun showOptionsMenu(context: Context) {
val colors = LogBoxTheme.getM3Colors(context)
lateinit var dialog: AlertDialog
val container = LogBoxComponents.createMenuContainer(context, colors, 20)
container.addView(LogBoxComponents.createTitle(context, colors, "Options", center = true))
val injectionDisabled = LogBoxUtils.isBundleInjectionDisabled(context)
val injectionText = if (injectionDisabled) "Enable Bundle Injection" else "Disable Bundle Injection"
container.addView(LogBoxComponents.createM3Button(context, injectionText, colors) {
dialog.dismiss()
LogBoxActions.toggleBundleInjection(context)
GoonXposed.xposed.Utils.Companion.reloadApp()
})
container.addView(LogBoxComponents.createM3Button(context, "Refetch Bundle", colors) {
dialog.dismiss()
RecoveryMenu.showConfirmAction(
context,
"Refetch Bundle",
"This will download the latest bundle from Github."
) {
LogBoxActions.refetchBundle(context)
}
})
container.addView(LogBoxComponents.createM3Button(context, "Clear Cache & Reset", colors) {
dialog.dismiss()
RecoveryMenu.showConfirmAction(
context,
"Clear Cache & Reset",
"This will clear all cached bundles and reset to default settings."
) {
LogBoxActions.clearCacheAndReset(context)
}
})
container.addView(LogBoxComponents.createM3Button(context, "Back", colors) {
dialog.dismiss()
RecoveryMenu.showRecoveryMenu(context)
})
dialog = LogBoxDialogs.createDialog(context, container)
LogBoxDialogs.currentMenuContainer = container
dialog.show()
LogBoxDialogs.setDialogWindowWidth(dialog, context)
}
}

View file

@ -1,143 +0,0 @@
package GoonXposed.xposed.modules.LogBox
import android.app.AlertDialog
import android.content.Context
import android.widget.LinearLayout
import GoonXposed.xposed.Utils.Log
object RecoveryMenu {
fun showRecoveryMenu(context: Context) {
Log.e("showRecoveryMenu called with context: $context")
try {
val colors = LogBoxTheme.getM3Colors(context)
lateinit var dialog: AlertDialog
val container = LogBoxComponents.createMenuContainer(context, colors)
container.addView(LogBoxComponents.createTitle(context, colors, "GoonXposed Recovery", center = true))
container.addView(LogBoxComponents.createSubtitle(context, colors, "Select an option to continue"))
val safeModeText = if (LogBoxUtils.isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode"
container.addView(LogBoxComponents.createM3Button(context, safeModeText, colors) {
dialog.dismiss()
handleMenuSelection(context, 0)
})
container.addView(LogBoxComponents.createM3Button(context, "Load Custom Bundle", colors) {
dialog.dismiss()
handleMenuSelection(context, 2)
})
container.addView(LogBoxComponents.createM3Button(context, "Reload App", colors) {
dialog.dismiss()
handleMenuSelection(context, 3)
})
val optionsBtn = LogBoxComponents.createM3Button(context, "Options", colors) {
dialog.dismiss()
handleMenuSelection(context, 5)
}
(optionsBtn.layoutParams as? LinearLayout.LayoutParams)?.let { lp ->
lp.setMargins(lp.leftMargin, LogBoxUtils.dpToPx(context, 16), lp.rightMargin, lp.bottomMargin)
optionsBtn.layoutParams = lp
}
container.addView(optionsBtn)
dialog = LogBoxDialogs.createDialog(context, container)
LogBoxDialogs.currentMenuContainer = container
dialog.show()
LogBoxDialogs.setDialogWindowWidth(dialog, context)
Log.e("Recovery menu shown successfully")
} catch (e: Exception) {
Log.e("Error showing recovery menu: ${e.message}", e)
throw e
}
}
private fun handleMenuSelection(context: Context, index: Int) {
when (index) {
0 -> LogBoxActions.toggleSafeMode(context)
1 -> showConfirmAction(context, "Refetch Bundle", "This will download the latest bundle from Github.") {
LogBoxActions.refetchBundle(context)
}
2 -> CustomBundleDialog.showCustomBundleDialog(context)
3 -> GoonXposed.xposed.Utils.Companion.reloadApp()
4 -> showConfirmAction(
context,
"Clear Cache & Reset",
"This will clear all cached bundles and reset to default settings."
) {
LogBoxActions.clearCacheAndReset(context)
}
5 -> OptionsMenu.showOptionsMenu(context)
}
}
fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) {
val colors = LogBoxTheme.getM3Colors(context)
val container = LogBoxComponents.createMenuContainer(context, colors)
container.addView(LogBoxComponents.createTitle(context, colors, title))
val messageView = android.widget.TextView(context).apply {
text = message
textSize = 14f
setTextColor(colors.onSurfaceVariant)
setPadding(0, 0, 0, LogBoxUtils.dpToPx(context, 24))
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
lineHeight = LogBoxUtils.dpToPx(context, 20)
}
}
container.addView(messageView)
val buttonContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.END
}
val cancelButton = android.widget.TextView(context).apply {
text = "Cancel"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val confirmButton = android.widget.TextView(context).apply {
text = "Confirm"
setTextColor(colors.primary)
textSize = 14f
typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL)
setPadding(
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10),
LogBoxUtils.dpToPx(context, 16),
LogBoxUtils.dpToPx(context, 10)
)
}
val dialog = LogBoxDialogs.createDialog(context, container)
cancelButton.setOnClickListener { dialog.dismiss() }
confirmButton.setOnClickListener {
dialog.dismiss()
action()
}
buttonContainer.addView(cancelButton)
buttonContainer.addView(confirmButton)
container.addView(buttonContainer)
LogBoxDialogs.currentMenuContainer = container
dialog.show()
LogBoxDialogs.setDialogWindowWidth(dialog, context)
}
}

View file

@ -1,84 +0,0 @@
package GoonXposed.xposed.modules
import android.content.res.AssetManager
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.BuildConfig
import GoonXposed.xposed.Constants
import GoonXposed.xposed.MethodHookBuilder
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.hook
import GoonXposed.xposed.method
import GoonXposed.xposed.safeLoadClass
import GoonXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.File
// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule if needed
class PayloadGlobalModule(private val modules: List<Module>) : Module() {
private companion object {
const val GLOBAL_NAME = "__PYON_LOADER__"
}
private fun getPayloadString(): String = JSON.encodeToString(
buildJsonObject {
put("loaderName", Constants.LOADER_NAME)
put("loaderVersion", BuildConfig.VERSION_NAME)
@Suppress("DEPRECATION") for (module in modules) module.buildPayload(this)
})
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
val catalystInstance = classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl")
val scriptLoader = classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance\$loadJSBundle$1")
?: classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1")
val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit = { param, key, json ->
runCatching {
// Attempt old CatalystInstanceImpl method
catalystInstance!!.method(
"setGlobalVariable", String::class.java, String::class.java
).invoke(param.thisObject, key, json)
}.onFailure {
// Failed (likely because it's a stub), use a workaround
val preloadsDir = File("${appInfo.dataDir}/${Constants.FILES_DIR}", PRELOADS_DIR)
File(preloadsDir, "rv_globals_$key.js").apply {
writeText("this[${JSON.encodeToString(key)}]=$json")
XposedBridge.invokeOriginalMethod(
scriptLoader!!.method(
"loadScriptFromFile",
String::class.java,
String::class.java,
Boolean::class.javaPrimitiveType!!
), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2])
)
delete()
}
}
}
val hook = MethodHookBuilder().run {
before {
setGlobalVariable(param, GLOBAL_NAME, getPayloadString())
}
build()
}
listOf(catalystInstance, scriptLoader).forEach { if (it != null) hook(it, hook) }
}
private fun hook(instance: Class<*>, hook: XC_MethodHook) {
instance.method(
"loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType!!,
).hook(hook)
instance.method(
"loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType!!
).hook(hook)
}
}

View file

@ -1,317 +0,0 @@
package GoonXposed.xposed.modules
import de.robv.android.xposed.callbacks.XC_LoadPackage
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.put
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import java.io.File
/**
* PerfPatchesModule
*
* Writes a small JavaScript preload into the module's `preloads` directory.
* The preload performs guarded, best-effort runtime monkeypatches to:
* - add memoization to expensive pure functions (if present)
* - cache RowGenerator.generate outputs on instances when inputs look stable
* - batch analytics/log events to avoid high-frequency synchronous work
*
* The implementation is intentionally defensive: it never throws and only applies
* patches if the targeted symbols exist.
*/
object PerfPatchesModule : Module() {
private const val FILE_NAME = "perf_patches.js"
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 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)
}
} catch (e: Throwable) {
Log.e("PerfPatchesModule:onLoad failed", e)
}
}
@Deprecated("Overrides deprecated Module.buildPayload")
override fun buildPayload(builder: JsonObjectBuilder) {
builder.put("perfPatches", true)
}
}

View file

@ -1,125 +0,0 @@
package GoonXposed.xposed.modules
import android.content.Context
import android.util.AtomicFile
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.asFile
import GoonXposed.xposed.modules.bridge.BridgeModule
import java.io.*
/**
* Manages the states of plugins, allowing reading and writing of plugin states to a file.
*
* ## Methods
*
* - `Shiggy.plugins.states.read(): { flags: { [pluginId: string]: number } }`
* - Reads the current plugin states from the file and returns them as a map.
*
* - `Shiggy.plugins.states.write(flags: { [pluginId: string]: number }): void`
* - Writes the provided plugin states to the file.
*/
class PluginsModule : Module() {
private companion object {
const val DATA_DIR = "Shiggy/plugins"
const val STATES_FILE = "states"
}
private lateinit var states: PluginStates
override fun onContext(context: Context) = with(context) {
val dataDir = File(filesDir, DATA_DIR)
val statesFile = File(
dataDir, STATES_FILE
).apply { asFile() }
BridgeModule.registerMethod("Shiggy.plugins.states.read") {
if (::states.isInitialized) states.toMap() else
PluginStates.loadFromFileOrNull(statesFile)?.let {
states = it
it.toMap()
}
}
BridgeModule.registerMethod("Shiggy.plugins.states.write") {
val (flags) = it
@Suppress("UNCHECKED_CAST")
states = PluginStates(
flags as Map<String, Double>
).apply { saveToFile(statesFile) }
Log.i("Plugin states saved: ${statesFile.absolutePath}")
}
}
}
private class UnsupportedPluginStatesVersionException(version: Int) :
Throwable("Unsupported plugin states version: $version")
data class PluginStates(
val flags: Map<String, Double>
) {
fun saveToFile(file: File) {
val atomic = AtomicFile(file)
var fos: FileOutputStream? = null
try {
fos = atomic.startWrite()
val out = DataOutputStream(BufferedOutputStream(fos))
out.writeInt(CURRENT_VERSION)
// Write flags
out.writeInt(flags.size)
for ((id, flags) in flags) {
out.writeUTF(id)
out.writeInt(flags.toInt())
}
out.flush()
atomic.finishWrite(fos)
} catch (t: Throwable) {
if (fos != null) atomic.failWrite(fos)
throw t
}
}
fun toMap(): Map<String, Any> = mapOf("flags" to flags)
companion object {
const val CURRENT_VERSION = 1
fun loadFromFileOrNull(file: File): PluginStates? {
if (!file.exists() || file.length() <= 0L) return null
val atomic = AtomicFile(file)
try {
DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
when (val version = input.readInt()) {
1 -> return loadV1FromFileOrNull(input)
else -> throw UnsupportedPluginStatesVersionException(version)
}
}
} catch (e: UnsupportedPluginStatesVersionException) {
Log.i(e.message!!)
} catch (e: EOFException) {
Log.e("Plugin states corrupt: ${e.message}")
} catch (e: IOException) {
Log.e("Failed to read plugin states: ${e.message}")
}
runCatching { file.delete() }
return null
}
private fun loadV1FromFileOrNull(input: DataInputStream): PluginStates {
val flagsSize = input.readInt()
val flags = mutableMapOf<String, Double>()
repeat(flagsSize) {
flags[input.readUTF()] = input.readInt().toDouble()
}
return PluginStates(flags)
}
}
}

View file

@ -1,176 +0,0 @@
package GoonXposed.xposed.modules
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.util.AtomicFile
import android.widget.Toast
import androidx.core.util.writeBytes
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.Utils.Companion.reloadApp
import GoonXposed.xposed.Utils.Log
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import java.io.File
import java.lang.ref.WeakReference
@Serializable
data class CustomLoadUrl(
val enabled: Boolean = false, val url: String = ""
)
@Serializable
data class LoaderConfig(
val customLoadUrl: CustomLoadUrl = CustomLoadUrl()
)
/**
* Module that updates the JS bundle by downloading it from a remote URL.
*
* Shows dialogs when failed allowing retry.
*/
object UpdaterModule : Module() {
private lateinit var config: LoaderConfig
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var lastActivity: WeakReference<Activity>? = null
private lateinit var cacheDir: File
private lateinit var bundle: File
private lateinit var etag: File
// Path to the application's data directory so we can read LogBox settings
private lateinit var appDataDir: File
private const val TIMEOUT_CACHED = 5000L
private const val TIMEOUT = 10000L
private const val ETAG_FILE = "etag.txt"
private const val CONFIG_FILE = "loader.json"
private const val DEFAULT_BUNDLE_URL =
"https://github.com/OTKUSteyler/GoonCord/releases/latest/download/gooncord.js"
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
// store app data dir for later checks (LogBox settings live under files/logbox)
appDataDir = File(appInfo.dataDir)
cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() }
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
etag = File(cacheDir, ETAG_FILE)
val configFile = File(filesDir, CONFIG_FILE)
config = runCatching {
if (configFile.exists()) {
JSON.decodeFromString<LoaderConfig>(configFile.readText())
} else LoaderConfig()
}.getOrDefault(LoaderConfig())
}
fun downloadScript(activity: Activity? = null): Job = scope.launch {
try {
// Respect LogBox setting to disable bundle injections.
// If the file "files/logbox/LOGBOX_SETTINGS" contains the flag bundleInjectionDisabled=true,
// skip attempting to download/update the bundle.
try {
val settingsFile = File(appDataDir, "files/logbox/LOGBOX_SETTINGS")
if (settingsFile.exists()) {
val txt = settingsFile.readText()
// look for a simple true marker; this avoids introducing a new JSON dependency here
if (txt.contains("\"bundleInjectionDisabled\"") && txt.contains("true")) {
Log.i("UpdaterModule: bundle injection disabled by LogBox settings — skipping download")
return@launch
}
}
} catch (ignore: Exception) {
// If anything goes wrong reading the setting, proceed with normal behaviour.
}
HttpClient(CIO) {
expectSuccess = false
install(UserAgent) { agent = Constants.USER_AGENT }
install(HttpRedirect) {}
}.use { client ->
val url = config.customLoadUrl.takeIf { it.enabled }?.url ?: DEFAULT_BUNDLE_URL
Log.i("Fetching JS bundle from: $url")
val response: HttpResponse = client.get(url) {
headers {
if (etag.exists() && bundle.exists()) {
append(HttpHeaders.IfNoneMatch, etag.readText())
}
}
// Retries don't need timeout
if (activity == null) {
timeout {
requestTimeoutMillis = if (!bundle.exists()) TIMEOUT else TIMEOUT_CACHED
}
}
}
when (response.status) {
HttpStatusCode.OK -> {
val bytes: ByteArray = response.body()
AtomicFile(bundle).writeBytes(bytes)
val newTag = response.headers[HttpHeaders.ETag]
if (!newTag.isNullOrEmpty()) etag.writeText(newTag) else etag.delete()
Log.i("Bundle updated (${bytes.size} bytes)")
// This is a retry, so we show a dialog
if (activity != null) {
withContext(Dispatchers.Main) {
AlertDialog.Builder(activity).setTitle("GoonCord Update Successful")
.setMessage("A reload is required for changes to take effect.")
.setPositiveButton("Reload") { dialog, _ ->
reloadApp()
dialog.dismiss()
}.setCancelable(false).show()
}
}
}
HttpStatusCode.NotModified -> {
Log.i("Server responded with 304, no changes")
}
else -> {
throw ResponseException(response, "Received status: ${response.status}")
}
}
}
} catch (e: Throwable) {
Log.e("Failed to download script", e)
showErrorDialog(e)
}
}
override fun onActivity(activity: Activity) {
lastActivity = WeakReference(activity)
}
fun showErrorDialog(e: Throwable) {
val activity = lastActivity?.get() ?: return
activity.runOnUiThread {
}
}
fun resetLoaderConfig(context: Context) {
val filesDir = File(context.dataDir, Constants.FILES_DIR)
val config = File(filesDir, CONFIG_FILE)
if (config.exists()) config.delete()
}
}

View file

@ -1,250 +0,0 @@
@file:Suppress("DEPRECATION")
package GoonXposed.xposed.modules.appearance
import android.content.res.AssetManager
import android.graphics.Typeface
import android.graphics.Typeface.CustomFallbackBuilder
import android.graphics.fonts.Font
import android.graphics.fonts.FontFamily
import android.os.Build
import de.robv.android.xposed.XC_MethodReplacement
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.Utils.Log
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.put
import java.io.File
import java.io.IOException
// 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,
val description: String? = null,
val spec: Int? = null,
val main: Map<String, String>,
)
object FontsModule : Module() {
private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic")
private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
private const val FONTS_ASSET_PATH = "fonts/"
private lateinit var fontsDir: File
private lateinit var fontsDownloadsDir: File
private var fontsAbsPath: String? = null
override fun buildPayload(builder: JsonObjectBuilder) {
builder.apply {
put("fontPatch", 2)
}
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
XposedHelpers.findAndHookMethod(
"com.facebook.react.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 fontDefFile = File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply { asFile() }
if (!fontDefFile.exists()) return@with
val fontDef = try {
JSON.decodeFromString<FontDefinition>(fontDefFile.readText())
} catch (_: Throwable) {
return@with
}
fontsDownloadsDir = File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { asDir() }
fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() }
fontsAbsPath = fontsDir.absolutePath + "/"
fontsDir.listFiles()?.forEach { file ->
val fileName = file.name
if (!fileName.startsWith(".")) {
val fontName = fileName.split('.')[0]
if (fontDef.main.keys.none { it == fontName }) {
Log.i("Deleting font file: $fileName")
file.delete()
}
}
}
// These files should be downloaded by the JS side, but oh well
CoroutineScope(Dispatchers.IO).launch {
val client = HttpClient(CIO) {
install(UserAgent) { agent = Constants.USER_AGENT }
install(HttpTimeout) {
requestTimeoutMillis = 15_000
connectTimeoutMillis = 10_000
socketTimeoutMillis = 15_000
}
}
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)
}
}
}.awaitAll()
} finally {
client.close()
}
}
}
private fun createAssetTypefaceWithFallbacks(
fontFamilyNames: Array<String>, style: Int, assetManager: AssetManager
): Typeface? {
val fontFamilies: MutableList<FontFamily> = ArrayList()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Iterate over the list of fontFamilyNames, constructing new FontFamily objects
// for use in the CustomFallbackBuilder below.
for (fontFamilyName in fontFamilyNames) {
try {
for (fileExtension in FILE_EXTENSIONS) {
val (customName, refName) = fontFamilyName.split(":")
val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { asFile() }
val font = Font.Builder(file).build()
val family = FontFamily.Builder(font).build()
fontFamilies.add(family)
}
} catch (_: Throwable) {
}
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
for (fileExtension in FILE_EXTENSIONS) {
val fileName =
StringBuilder().append(fontRootPath).append(fontFamilyName).append(fileExtension).toString()
try {
val builder = if (fileName[0] == '/') Font.Builder(File(fileName)) else Font.Builder(
assetManager, fileName
)
val font = builder.build()
val family = FontFamily.Builder(font).build()
fontFamilies.add(family)
} catch (_: RuntimeException) {
// If the typeface asset does not exist, try another extension.
continue
} catch (_: IOException) {
// If the font asset does not exist, try another extension.
continue
}
}
}
}
// If there's some problem constructing fonts, fall back to the default behavior.
if (fontFamilies.isEmpty()) return createAssetTypeface(fontFamilyNames[0], style, assetManager)
val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0])
for (i in 1 until fontFamilies.size) {
fallbackBuilder.addCustomFallback(fontFamilies[i])
}
return fallbackBuilder.build()
}
return null
}
private fun createAssetTypeface(
fontFamilyName: String, style: Int, assetManager: AssetManager
): Typeface? {
// This logic attempts to safely check if the frontend code is attempting to use
// fallback fonts, and if it is, to use the fallback typeface creation logic.
var fontFamilyName: String = fontFamilyName
val fontFamilyNames = fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
for (i in fontFamilyNames.indices) {
fontFamilyNames[i] = fontFamilyNames[i].trim()
}
// If there are multiple font family names:
// For newer versions of Android, construct a Typeface with fallbacks
// For older versions of Android, ignore all the fallbacks and just use the first font family
if (fontFamilyNames.size > 1) {
fontFamilyName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return createAssetTypefaceWithFallbacks(fontFamilyNames, style, assetManager)
} else {
fontFamilyNames[0]
}
}
val extension = EXTENSIONS[style]
try {
for (fileExtension in FILE_EXTENSIONS) {
val (customName, refName) = fontFamilyName.split(":")
val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { asFile() }
if (!file.exists()) throw Exception()
return Typeface.createFromFile(file.absolutePath)
}
} catch (_: Throwable) {
}
// Lastly, after all those checks above, this is the original RN logic for
// getting the typeface.
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
for (fileExtension in FILE_EXTENSIONS) {
val fileName =
StringBuilder().append(fontRootPath).append(fontFamilyName).append(extension).append(fileExtension)
.toString()
return try {
if (fileName[0] == '/') Typeface.createFromFile(fileName)
else Typeface.createFromAsset(assetManager, fileName)
} catch (_: RuntimeException) {
// If the typeface asset does not exist, try another extension.
continue
}
}
}
return Typeface.create(fontFamilyName, style)
}
}

View file

@ -1,49 +0,0 @@
package GoonXposed.xposed.modules.appearance
import android.R.color
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
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())
val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2")
val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
builder.apply {
put("isSysColorsSupported", isSupported())
if (isSupported()) putJsonObject("sysColors") {
for (accent in accents) putJsonArray(accent) {
for (shade in shades) {
val colorName = "system_" + accent + "_" + shade
val colorResourceId = runCatching {
color::class.java.getField(colorName).getInt(null)
}.getOrElse { 0 }
add(convertToColor(colorResourceId))
}
}
}
}
}
private fun convertToColor(id: Int): String {
val ctx = context.get()
val clr = if (isSupported() && ctx != null) ContextCompat.getColor(ctx, id) else 0
return String.format("#%06X", 0xFFFFFF and clr)
}
}

View file

@ -1,184 +0,0 @@
package GoonXposed.xposed.modules.appearance
import android.content.Context
import android.content.res.Resources
import androidx.core.graphics.toColorInt
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Constants
import GoonXposed.xposed.MethodHookBuilder
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.asFile
import GoonXposed.xposed.hook
import GoonXposed.xposed.method
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import java.io.File
@Serializable
data class Author(
val name: String, val id: String? = null
)
@Serializable
data class ThemeData(
val name: String,
val description: String? = null,
val authors: List<Author>? = null,
val spec: Int,
val semanticColors: Map<String, List<String>>? = null,
val rawColors: Map<String, String>? = null
)
@Serializable
data class Theme(
val id: String, val selected: Boolean, val data: ThemeData
)
object ThemesModule : Module() {
private lateinit var param: XC_LoadPackage.LoadPackageParam
private var theme: Theme? = null
private val rawColorMap = mutableMapOf<String, Int>()
private const val THEME_FILE = "current-theme.json"
@Deprecated("This method is deprecated in the parent class")
@ExperimentalSerializationApi
override fun buildPayload(builder: JsonObjectBuilder) {
builder.apply {
put("hasThemeSupport", true)
if (theme != null) put("storedTheme", JSON.encodeToJsonElement<Theme>(theme!!))
else put("storedTheme", null)
}
}
private fun String.fromScreamingSnakeToCamelCase() =
this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } }
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
param = packageParam
theme = getTheme()
hookTheme()
}
private fun File.isValidThemeFile(): Boolean {
if (!this.exists()) return false
val text = this.readText()
return text.isNotBlank() && text != "{}" && text != "null"
}
private fun getTheme(): Theme? {
val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { asFile() }
if (!themeFile.isValidThemeFile()) return null
return try {
val themeText = themeFile.readText()
JSON.decodeFromString<Theme>(themeText)
} catch (_: Exception) {
null
}
}
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")
hookThemeInternal(themeManager, darkTheme, lightTheme)
} catch (e: Throwable) {
GoonXposed.xposed.Utils.Log.e("Failed to hook theme: ${e.message}")
}
}
private fun hookThemeInternal(
themeManager: Class<*>, darkTheme: Class<*>, lightTheme: Class<*>
) {
val theme = this.theme ?: return
// Apply rawColors
theme.data.rawColors?.forEach { (key, value) ->
rawColorMap[key.lowercase()] = hexStringToColorInt(value)
}
// Apply semanticColors
theme.data.semanticColors?.forEach { (key, value) ->
// TEXT_NORMAL -> getTextNormal
val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
value.forEachIndexed { index, v ->
when (index) {
0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v))
1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v))
}
}
}
// If there's any rawColors value, hook the color getter
if (!theme.data.rawColors.isNullOrEmpty()) {
try {
val getColorCompat = themeManager.getDeclaredMethod(
"getColorCompat",
Resources::class.java,
Int::class.javaPrimitiveType,
Resources.Theme::class.java,
)
val getColorCompatLegacy = themeManager.getDeclaredMethod(
"getColorCompat", Context::class.java, Int::class.javaPrimitiveType
)
val patch = MethodHookBuilder().run {
before {
try {
val arg1 = args[0]
val resources = if (arg1 is Context) arg1.resources else (arg1 as Resources)
val name = resources.getResourceEntryName(args[1] as Int)
if (rawColorMap[name] != null) result = rawColorMap[name]
} catch (_: Throwable) {
}
}
build()
}
getColorCompat.hook(patch)
getColorCompatLegacy.hook(patch)
} catch (e: Throwable) {
GoonXposed.xposed.Utils.Log.e("Failed to hook getColorCompat: ${e.message}")
}
}
}
// Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB"
private fun hexStringToColorInt(hexString: String): Int {
return try {
if (hexString.length == 9) {
// Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy
val alpha = hexString.substring(7, 9)
val rrggbb = hexString.substring(1, 7)
"#$alpha$rrggbb".toColorInt()
} else hexString.toColorInt()
} catch (_: Throwable) {
0
}
}
private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
try {
themeClass.getDeclaredMethod(methodName).let { method ->
method.hook {
before {
result = themeValue
}
}
}
} catch (_: NoSuchMethodException) {
}
}
}

View file

@ -1,95 +0,0 @@
package GoonXposed.xposed.modules.bridge
import android.app.Activity
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.widget.Toast
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils
import GoonXposed.xposed.getAppInfo
import java.io.File
object AdditionalBridgeMethodsModule : Module() {
override fun onContext(context: Context) = with(context) {
BridgeModule.registerMethod("Shiggy.fs.getConstants") {
mapOf(
"data" to dataDir.absolutePath,
"files" to filesDir.absolutePath,
"cache" to cacheDir.absolutePath,
)
}
BridgeModule.registerMethod("Shiggy.fs.delete") {
val (path) = it
File(path as String).run {
if (this.isDirectory) this.deleteRecursively()
else this.delete()
}
}
BridgeModule.registerMethod("Shiggy.fs.exists") {
val (path) = it
File(path as String).exists()
}
BridgeModule.registerMethod("Shiggy.fs.read") { it ->
val (path) = it
val file = File(path as String).apply { openFileGuarded() }
file.bufferedReader().use { it.readText() }
}
BridgeModule.registerMethod("Shiggy.fs.write") {
val (path, contents) = it
val file = File(path as String).apply { openFileGuarded() }
file.writeText(contents as String)
}
}
override fun onActivity(activity: Activity) = with(activity) {
BridgeModule.registerMethod("Shiggy.alertError") {
val (error, version) = it
val app = getAppInfo()
val errorString = "$error"
val clipboard = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Stack Trace", errorString)
AlertDialog.Builder(this)
.setTitle("GoonCord Error")
.setMessage(
"""
GoonCord: $version
${app.name}: ${app.version} (${app.versionCode})
Device: ${Build.MANUFACTURER} ${Build.MODEL}
""".trimIndent() + errorString
)
.setPositiveButton("OK") { dialog, _ ->
dialog.dismiss()
}
.setNeutralButton("Copy") { dialog, _ ->
clipboard.setPrimaryClip(clip)
Toast.makeText(applicationContext, "Copied stack trace", Toast.LENGTH_SHORT).show()
dialog.dismiss()
}
.show()
null
}
BridgeModule.registerMethod("Shiggy.showRecoveryAlert") {
Utils.showRecoveryAlert(this)
}
}
private fun File.openFileGuarded() {
if (!this.exists()) throw Error("Path does not exist: $path")
if (!this.isFile) throw Error("Path is not a file: $path")
}
}

View file

@ -1,159 +0,0 @@
package GoonXposed.xposed.modules.bridge
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.BuildConfig
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.hookMethod
import GoonXposed.xposed.method
import java.lang.reflect.Method
/**
* See for possible return types:
* https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19
*
* You may return a [Unit] and the resulting value will be `null`.
*/
typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any?
typealias BridgeMethodArgs = ArrayList<Any>
/**
* A module that exposes a bridge for calling methods from JavaScript.
*
* To call a method, pass an object with the following structure to a hooked method:
* ```js
* {
* Shiggy: {
* method: "method.name",
* args: [arg1, arg2, ...]
* }
* }
* ```
*
* For methods that accept additional arguments, only the first object argument is used for the bridge call.
*
* The result will be an object with either a `result` or `error` key:
* ```js
* {
* result: ... // The return value of the method
* }
* ```
* or
* ```js
* {
* error: "Error message"
* }
* ```
*/
object BridgeModule : Module() {
private lateinit var readableMapGetString: Method
private lateinit var readableMapToHashMap: Method
private lateinit var argumentsMakeNative: Method
private const val CALL_DATA_KEY = "Shiggy"
private const val METHOD_NAME_KEY = "method"
private const val METHOD_ARGS_KEY = "args"
private val methods: MutableMap<String, BridgeMethodCallback> = mutableMapOf()
/**
* Registers a bridge method that can be called from JavaScript.
*
* If the method is already registered, it will be overwritten.
*
* @param name The name of the method to register.
* @param callback The callback to invoke when the method is called.
*/
fun registerMethod(name: String, callback: BridgeMethodCallback) {
if (methods.containsKey(name)) Log.w("Bridge method already exists and will be overridden: $name")
methods[name] = callback
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments")
val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap")
val promise = classLoader.loadClass("com.facebook.react.bridge.Promise")
val promiseResolve = promise.method("resolve", Object::class.java)
argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java)
readableMapGetString = readableMap.method("getString", String::class.java)
readableMapToHashMap = readableMap.method("toHashMap")
classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager")
.hookMethod("getBBox", Double::class.javaObjectType, readableMap) {
before {
callBridgeMethod(readableMapToHashMap(args[1]!!))?.let { result = it.toNativeObject() }
}
}
classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule")
.hookMethod("readAsDataURL", readableMap, promise) {
before {
val (readableMap, promise) = args
callBridgeMethod(readableMapToHashMap(readableMap!!))?.let {
promiseResolve.invoke(promise, it.toNativeObject())
result = null
}
}
}
// Clear methods every package load. Plugins should re-register on each load.
methods.clear()
registerDefaultMethods()
return@with
}
private fun registerDefaultMethods() {
methods["Shiggy.info"] = {
mapOf(
"name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE
)
}
methods["Shiggy.test"] = {
mapOf(
"string" to "string",
"number" to 7256,
"array" to listOf("testing", 527737, listOf(true)),
"object" to mapOf("nested" to true),
"boolean" to false,
"args" to it,
)
}
}
private fun Any?.toNativeObject(): Any? = argumentsMakeNative.invoke(
null, when (this) {
Unit -> null
else -> this
}
)
private fun readableMapToHashMap(map: Any): HashMap<String, Any?> {
@Suppress("UNCHECKED_CAST") return readableMapToHashMap.invoke(map) as HashMap<String, Any?>
}
private fun callBridgeMethod(hashMap: HashMap<String, Any?>): Map<String, Any?>? = try {
val (method, args) = getBridgeCallData(hashMap) ?: return null
val ret = method(args).toNativeObject()
mapOf("result" to ret)
} catch (e: Throwable) {
mapOf("error" to e.stackTraceToString())
}
private fun getBridgeCallData(hashMap: HashMap<String, Any?>): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
@Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
data ?: return null
@Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String
val method = methods[name]
method ?: throw Error("Method not registered: $name")
@Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs
return Pair(method, args)
}
}

View file

@ -1,52 +0,0 @@
package GoonXposed.xposed.modules.no_track
import android.content.Context
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.hookMethod
import GoonXposed.xposed.safeLoadClass
/**
* Hooks Discord's crash reporting to disable Sentry initialization.
* While Discord doesn't have Sentry auto-initialization on by default, we still hook the content provider just to be safe.
*/
object BlockCrashReportingModule : Module() {
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
val crashReportingClass = classLoader.safeLoadClass("com.discord.crash_reporting.CrashReporting")
crashReportingClass?.apply {
// This only exists on 30720x and above
runCatching {
hookMethod("isDisabled") {
before {
Log.i("Forced CrashReporting.isDisabled() to true")
result = true
}
}
}.onFailure {
// In older versions, this hook works fine.
// Hooking this on 30720x will result in a crash after a few seconds,
// since Discord asserts initialization when setting a Sentry tag.
hookMethod(
"init", Context::class.java, String::class.java
) {
before {
Log.i("Blocked CrashReporting initialization")
result = null
}
}
}
}
val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider")
sentryInitProviderClass?.hookMethod("onCreate") {
before {
Log.i("Blocked SentryInitProvider initialization")
result = true
}
}
return@with
}
}

View file

@ -1,27 +0,0 @@
package GoonXposed.xposed.modules.no_track
import android.content.Context
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.hookMethod
import GoonXposed.xposed.safeLoadClass
/**
* Hooks Discord's deep links tracking to disable AppsFlyer initialization.
*/
object BlockDeepLinksTrackingModule : Module() {
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
val deepLinksClass = classLoader.safeLoadClass("com.discord.deep_link.DeepLinks")
deepLinksClass?.hookMethod(
"init", Context::class.java
) {
before {
Log.i("Blocked DeepLinks tracking initialization")
result = null
}
}
return@with
}
}

View file

@ -9,8 +9,7 @@ class Constants {
const val CACHE_DIR = "cache/pyoncord" const val CACHE_DIR = "cache/pyoncord"
const val MAIN_SCRIPT_FILE = "bundle.js" const val MAIN_SCRIPT_FILE = "bundle.js"
const val LOG_TAG = "GoonXposed"
const val LOG_TAG = "Goon"
const val LOADER_NAME = "GoonXposed" const val LOADER_NAME = "GoonXposed"

View file

@ -0,0 +1,70 @@
package GoonXposed.xposed
import GoonXposed.xposed.modules.*
import GoonXposed.xposed.module.appearance.ThemesModule
import GoonXposed.xposed.modules.appearance.FontsModule
import GoonXposed.xposed.modules.appearance.SysColorsModule
import GoonXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule
import GoonXposed.xposed.modules.bridge.BridgeModule
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.os.Bundle
import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.IXposedHookZygoteInit
import de.robv.android.xposed.callbacks.XC_LoadPackage
class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
private var hooked = false
private val modules =
mutableListOf(
HookScriptLoaderModule(),
BridgeModule(),
AdditionalBridgeMethodsModule(),
UpdaterModule(),
FixResourcesModule(),
LogBoxModule(),
FontsModule(),
SysColorsModule(),
ThemesModule(),
)
init {
modules.add(PayloadGlobalModule(modules))
}
override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
for (module in modules) module.onInit(startupParam)
}
override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) =
with(param) {
if (hooked) return
val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY)
ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) {
after { this@Main.onContext(args[0] as Context) }
}
reactActivity.hookMethod("onCreate", Bundle::class.java) {
after { this@Main.onActivity(thisObject as Activity) }
}
this@Main.onLoad(param)
hooked = true
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
for (module in modules) module.onLoad(packageParam)
}
override fun onContext(context: Context) {
for (module in modules) module.onContext(context)
}
override fun onActivity(activity: Activity) {
for (module in modules) module.onActivity(activity)
}
}

View file

@ -0,0 +1,192 @@
package GoonXposed.xposed
import android.app.Activity
import android.content.Context
import android.os.Build
import de.robv.android.xposed.IXposedHookZygoteInit
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.callbacks.XC_LoadPackage
import java.io.File
import java.lang.reflect.Method
import kotlinx.serialization.json.JsonObjectBuilder
data class AppInfo(
val name: String,
val packageName: String,
val version: String,
val versionCode: Long,
)
abstract class Module {
/** Builds a JSON payload to be injected into the JavaScript context. */
@Deprecated(
"This will be removed in future versions. Payloads can be replaced via synchronous bridge methods."
)
open fun buildPayload(builder: JsonObjectBuilder) {}
/** Called during Zygote initialization. */
open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {}
/**
* Called when a package is loaded.
*
* Hooks should typically be set up here.
*
* Bridge methods that do not require [Context] can also be registered here.
*/
open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {}
/**
* Called after [Context] has been attached to a [android.content.ContextWrapper] via
* [android.content.ContextWrapper.attachBaseContext].
*
* This may be called multiple times for different [Context]s, e.g. when the app is killed by
* the system.
*
* For bridge methods that require access to [Context], register them here.
*/
open fun onContext(context: Context) {}
/**
* Called after an [Activity.onCreate] method is executed.
*
* UI code that requires an [Activity] context can be placed here.
*
* Bridge methods must not be registered here, as it can be too late and cause crashes.
*
* If you need to register bridge methods that require an [Activity], do it in [onContext] and
* make it async.
*
* Once an [Activity] is available, execute the queued calls. Then from JS, call the method
* asynchronously.
*/
open fun onActivity(activity: Activity) {}
protected fun Context.getAppInfo(): AppInfo {
val pInfo = packageManager.getPackageInfo(packageName, 0)
val versionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode
else @Suppress("DEPRECATION") pInfo.versionCode.toLong()
return AppInfo(
packageManager.getApplicationLabel(applicationInfo).toString(),
packageName,
pInfo.versionName ?: versionCode.toString(),
versionCode,
)
}
protected fun File.asDir() {
if (!this.isDirectory) this.delete()
this.mkdirs()
}
protected fun File.asFile() {
if (!this.isFile) this.deleteRecursively()
}
protected fun Class<*>.method(name: String, vararg params: Class<*>?): Method =
getDeclaredMethod(name, *params).apply { isAccessible = true }
protected fun ClassLoader.safeLoadClass(name: String): Class<*>? =
runCatching { loadClass(name) }.getOrNull()
protected fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook =
XposedBridge.hookMethod(this, hook)
protected fun Method.hook(block: MethodHookBuilder.() -> Unit): XC_MethodHook.Unhook =
hook(MethodHookBuilder().apply(block).build())
protected fun Class<*>.hookMethod(
name: String,
vararg params: Class<*>?,
block: MethodHookBuilder.() -> Unit
): XC_MethodHook.Unhook = method(name, *params).hook(MethodHookBuilder().apply(block).build())
protected class MethodHookBuilder {
private var beforeBlock: (HookScope.() -> Unit)? = null
private var afterBlock: (HookScope.() -> Unit)? = null
fun before(block: HookScope.() -> Unit) {
beforeBlock = block
}
fun after(block: HookScope.() -> Unit) {
afterBlock = block
}
fun build(): XC_MethodHook =
object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
val b = beforeBlock
if (b != null) {
val scope =
HookScope(
param = param,
proceed = { p -> super.beforeHookedMethod(p) }
)
scope.b()
} else {
super.beforeHookedMethod(param)
}
}
override fun afterHookedMethod(param: MethodHookParam) {
val a = afterBlock
if (a != null) {
val scope =
HookScope(
param = param,
proceed = { p -> super.afterHookedMethod(p) }
)
scope.a()
} else {
super.afterHookedMethod(param)
}
}
}
}
/**
* Scope object passed to before/after hook blocks.
*
* Provides:
* - Access to the [param] object
* - [proceed] to call the original XC_MethodHook super method
* - Accessors for `thisObject`, `args`, `result`, and `throwable`
*
* @property param The [XC_MethodHook.MethodHookParam] for the current hook.
* @property proceed Function that calls the super method.
*/
protected class HookScope
internal constructor(
val param: XC_MethodHook.MethodHookParam,
private val proceed: (XC_MethodHook.MethodHookParam) -> Unit
) {
/**
* Continues with the default XC_MethodHook super behavior. Equivalent to calling
* `super.beforeHookedMethod(param)` or `super.afterHookedMethod(param)` depending on the
* phase.
*/
fun proceed() = proceed(param)
val thisObject: Any?
get() = param.thisObject
val args: Array<Any?>
get() = param.args
var result: Any?
get() = param.result
set(value) {
param.result = value
}
var throwable: Throwable?
get() = param.throwable
set(value) {
param.throwable = value
}
}
}

View file

@ -0,0 +1,32 @@
package GoonXposed.xposed
import android.app.AndroidAppHelper
import android.content.Intent
import kotlin.system.exitProcess
import kotlinx.serialization.json.Json
class Utils {
companion object {
val JSON = Json { ignoreUnknownKeys = true }
fun reloadApp() {
val application = AndroidAppHelper.currentApplication()
val intent =
application.packageManager.getLaunchIntentForPackage(application.packageName)
application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
exitProcess(0)
}
}
object Log {
fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg)
fun e(msg: String, throwable: Throwable) =
android.util.Log.e(Constants.LOG_TAG, msg, throwable)
fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg)
fun i(msg: String, throwable: Throwable) =
android.util.Log.i(Constants.LOG_TAG, msg, throwable)
fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg)
fun w(msg: String, throwable: Throwable) =
android.util.Log.w(Constants.LOG_TAG, msg, throwable)
}
}

View file

@ -0,0 +1,21 @@
package GoonXposed.xposed.modules
import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE
import GoonXposed.xposed.Module
import android.content.res.Resources
import de.robv.android.xposed.callbacks.XC_LoadPackage
/** Hooks [Resources.getIdentifier] to fix resource package name mismatch. */
class FixResourcesModule : Module() {
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
with(packageParam) {
if (packageName != TARGET_PACKAGE) {
Resources::class.java.hookMethod(
"getIdentifier",
String::class.java,
String::class.java,
String::class.java
) { before { if (args[2] == packageName) args[2] = TARGET_PACKAGE } }
}
}
}

View file

@ -0,0 +1,133 @@
package GoonXposed.xposed.modules
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import GoonXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
import android.content.res.AssetManager
import android.content.res.XModuleResources
import de.robv.android.xposed.IXposedHookZygoteInit
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.callbacks.XC_LoadPackage
import java.io.File
import java.lang.reflect.Method
import kotlinx.coroutines.runBlocking
/**
* Hooks React Native's script loading methods to load custom scripts and bundles.
*
* Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files
* directory.
*
* The main bundle should be placed in the [Constants.CACHE_DIR] directory named
* [Constants.MAIN_SCRIPT_FILE]. If the bundle file does not exist, it will attempt to load
* `assets://revenge.bundle` from the module's assets.
*/
class HookScriptLoaderModule : Module() {
private lateinit var preloadsDir: File
private lateinit var mainScript: File
private lateinit var modulePath: String
private lateinit var resources: XModuleResources
companion object {
const val PRELOADS_DIR = "preloads"
}
override fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {
this@HookScriptLoaderModule.modulePath = startupParam.modulePath
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
with(packageParam) {
val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() }
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() }
preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() }
mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() }
listOf(
"com.facebook.react.runtime.ReactInstance$1",
// TODO: Remove once Discord fully switches to Bridgeless
"com.facebook.react.bridge.CatalystInstanceImpl"
)
.mapNotNull { classLoader.safeLoadClass(it) }
.forEach { hook(it) }
}
private fun hook(instance: Class<*>) {
val loadScriptFromAssets =
instance.method(
"loadScriptFromAssets",
AssetManager::class.java,
String::class.java,
Boolean::class.javaPrimitiveType
)
val loadScriptFromFile =
instance.method(
"loadScriptFromFile",
String::class.java,
String::class.java,
Boolean::class.javaPrimitiveType
)
loadScriptFromAssets.hook {
before {
Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})")
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
}
}
loadScriptFromFile.hook {
before {
Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
}
}
}
private fun HookScope.runCustomScripts(
loadScriptFromFile: Method,
loadScriptFromAssets: Method
) {
Log.i("Running custom scripts...")
// TODO: Is there a better way to do this?
runBlocking { UpdaterModule.job?.join() }
val loadSynchronously = args[2]
val runScriptFile = { file: File ->
Log.i("Loading script: ${file.absolutePath}")
XposedBridge.invokeOriginalMethod(
loadScriptFromFile,
thisObject,
arrayOf(file.absolutePath, file.absolutePath, loadSynchronously)
)
Unit
}
try {
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
if (mainScript.exists()) runScriptFile(mainScript)
else {
Log.i("Main script does not exist, falling back")
if (!::resources.isInitialized)
resources = XModuleResources.createInstance(modulePath, null)
XposedBridge.invokeOriginalMethod(
loadScriptFromAssets,
thisObject,
arrayOf(resources.assets, "assets://bundle.js", loadSynchronously)
)
}
} catch (e: Throwable) {
Log.e("Unable to run scripts:", e)
}
}
}

View file

@ -0,0 +1,601 @@
package GoonXposed.xposed.modules
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.EditText
import android.widget.Toast
import de.robv.android.xposed.callbacks.XC_LoadPackage
import GoonXposed.xposed.BuildConfig
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils
import GoonXposed.xposed.Utils.Log
import de.robv.android.xposed.XposedHelpers
import kotlinx.coroutines.*
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import GoonXposed.xposed.Utils.Companion.reloadApp
class LogBoxModule : Module() {
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
this@LogBoxModule.packageParam = packageParam
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
getUseDeveloperSupportMethod.hook {
before {
result = true
}
}
listOf(
"com.facebook.react.devsupport.BridgeDevSupportManager",
"com.facebook.react.devsupport.BridgelessDevSupportManager"
).mapNotNull { packageParam.classLoader.safeLoadClass(it) }.forEach {
hookDevSupportManager(it)
}
return@with
}
private fun hookDevSupportManager(clazz: Class<*>) {
val showDevOptionsDialogMethod = clazz.methods.first { it.name == "showDevOptionsDialog" }
showDevOptionsDialogMethod.hook {
before {
try {
val context = getContextFromDevSupport(clazz, thisObject)
if (context != null) {
showRecoveryMenu(context)
}
result = null
} catch (e: Exception) {
Log.e("Failed to show recovery menu: ${e.message}")
}
}
}
}
private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? {
return try {
val mReactInstanceDevHelperField = XposedHelpers.findField(clazz, "mReactInstanceDevHelper")
val helper = mReactInstanceDevHelperField.get(instance)
val getCurrentActivityMethod = helper?.javaClass?.methods?.first { it.name == "getCurrentActivity" }
getCurrentActivityMethod?.invoke(helper) as? Context
} catch (e: Exception) {
Log.e("Failed to get context: ${e.message}")
null
}
}
private fun reloadReactNative(context: Context) {
try {
val reactInstanceManagerClass =
packageParam.classLoader.loadClass("com.facebook.react.ReactInstanceManager")
val reactApplicationClass = packageParam.classLoader.loadClass("com.facebook.react.ReactApplication")
val app = context.applicationContext
if (reactApplicationClass.isInstance(app)) {
val getReactNativeHostMethod = reactApplicationClass.methods.first { it.name == "getReactNativeHost" }
val reactNativeHost = getReactNativeHostMethod.invoke(app)
val getReactInstanceManagerMethod =
reactNativeHost.javaClass.methods.first { it.name == "getReactInstanceManager" }
val reactInstanceManager = getReactInstanceManagerMethod.invoke(reactNativeHost)
if (reactInstanceManager != null) {
val reloadMethod =
reactInstanceManagerClass.methods.first { it.name == "recreateReactContextInBackground" }
reloadMethod.invoke(reactInstanceManager)
} else {
showError(context, "Error", "Could not get React Instance Manager")
}
} else {
showError(context, "Error", "Application is not a React Application")
}
} catch (e: Exception) {
Log.e("Failed to Reload App: ${e.message}")
showError(context, "Failed to reload", "Error: ${e.message}")
}
}
private fun showRecoveryMenu(context: Context) {
val options = arrayOf(
if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode",
"Refetch Bundle",
"Load Custom Bundle",
"Reload App",
"Clear Cache & Reset"
)
val adapter =
object : android.widget.ArrayAdapter<String>(context, android.R.layout.select_dialog_item, options) {
override fun getView(
position: Int,
convertView: android.view.View?,
parent: android.view.ViewGroup
): android.view.View {
val view = super.getView(position, convertView, parent)
(view as? android.widget.TextView)?.apply {
setTextColor(0xFFFFFFFF.toInt())
textSize = 14f
setPadding(
(16 * context.resources.displayMetrics.density).toInt(),
(12 * context.resources.displayMetrics.density).toInt(),
(16 * context.resources.displayMetrics.density).toInt(),
(12 * context.resources.displayMetrics.density).toInt()
)
setTypeface(typeface, android.graphics.Typeface.NORMAL)
}
view.setBackgroundColor(0xFF2D2D2D.toInt())
return view
}
}
val dialog = AlertDialog.Builder(context)
.setTitle("GoonXposed Recovery Menu")
.setAdapter(adapter) { _, which ->
handleMenuSelection(context, which)
}
.setNegativeButton("Close", null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply {
setTextColor(0xFFBB86FC.toInt())
setBackgroundColor(0xFF1E1E1E.toInt())
textSize = 12f
setPadding(
(20 * context.resources.displayMetrics.density).toInt(),
(8 * context.resources.displayMetrics.density).toInt(),
(20 * context.resources.displayMetrics.density).toInt(),
(8 * context.resources.displayMetrics.density).toInt()
)
}
dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt()))
val titleId = context.resources.getIdentifier("alertTitle", "id", "android")
val titleView = dialog.findViewById<android.widget.TextView>(titleId)
titleView?.apply {
setTextColor(0xFFBB86FC.toInt())
textSize = 16f
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
dialog.listView?.setBackgroundColor(0xFF1E1E1E.toInt())
dialog.listView?.divider = android.graphics.drawable.ColorDrawable(0xFF444444.toInt())
dialog.listView?.dividerHeight = 1
}
dialog.show()
}
private fun handleMenuSelection(context: Context, index: Int) {
when (index) {
0 -> toggleSafeMode(context)
1 -> showConfirmAction(
context, "Refetch Bundle",
"This will download the latest bundle from the official server."
) { refetchBundle(context) }
2 -> showCustomBundleDialog(context)
3 -> reloadApp()
4 -> showConfirmAction(
context, "Clear Cache & Reset",
"This will clear all cached bundles and reset to default settings."
) { clearCacheAndReset(context) }
}
}
private fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) {
val dialog = AlertDialog.Builder(context)
.setTitle(title)
.setMessage(message)
.setPositiveButton("Confirm") { _, _ -> action() }
.setNegativeButton("Cancel", null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply {
setTextColor(0xFFFFFFFF.toInt())
setBackgroundColor(0xFF1E1E1E.toInt())
textSize = 12f
}
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply {
setTextColor(0xFFBB86FC.toInt())
setBackgroundColor(0xFF1E1E1E.toInt())
textSize = 12f
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt()))
val titleId = context.resources.getIdentifier("alertTitle", "id", "android")
val titleView = dialog.findViewById<android.widget.TextView>(titleId)
titleView?.apply {
setTextColor(0xFFBB86FC.toInt())
textSize = 16f
}
val messageId = context.resources.getIdentifier("message", "id", "android")
val messageView = dialog.findViewById<android.widget.TextView>(messageId)
messageView?.apply {
setTextColor(0xFFFFFFFF.toInt())
textSize = 14f
setPadding(
(5 * context.resources.displayMetrics.density).toInt(),
paddingTop,
(5 * context.resources.displayMetrics.density).toInt(),
paddingBottom
)
}
}
dialog.show()
}
private fun showCustomBundleDialog(context: Context) {
val filesDir = File(context.filesDir, "pyoncord")
val configFile = File(filesDir, "loader.json")
var currentUrl: String? = null
if (configFile.exists()) {
try {
val json = JSONObject(configFile.readText())
val customLoadUrl = json.optJSONObject("customLoadUrl")
if (customLoadUrl != null && customLoadUrl.optBoolean("enabled", false)) {
currentUrl = customLoadUrl.optString("url", "")
}
} catch (_: Exception) {
}
}
val urlInput = EditText(context).apply {
hint = "http://localhost:4040/gooncord.js"
setTextColor(0xFFFFFFFF.toInt())
setBackgroundColor(0xFF2D2D2D.toInt())
setHintTextColor(0xFF888888.toInt())
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
setText(currentUrl ?: "")
gravity = android.view.Gravity.START
textAlignment = android.view.View.TEXT_ALIGNMENT_VIEW_START
textSize = 14f
setPadding(
(12 * context.resources.displayMetrics.density).toInt(),
(10 * context.resources.displayMetrics.density).toInt(),
(12 * context.resources.displayMetrics.density).toInt(),
(10 * context.resources.displayMetrics.density).toInt()
)
}
val container = android.widget.FrameLayout(context)
val params = android.widget.FrameLayout.LayoutParams(
(context.resources.displayMetrics.widthPixels * 0.85).toInt(),
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT
)
params.leftMargin = (15 * context.resources.displayMetrics.density).toInt()
params.rightMargin = (15 * context.resources.displayMetrics.density).toInt()
urlInput.layoutParams = params
container.addView(urlInput)
val dialog = AlertDialog.Builder(context)
.setTitle("Custom Bundle URL")
.setView(container)
.setPositiveButton("Save", null)
.setNegativeButton("Cancel", null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply {
setTextColor(0xFFBB86FC.toInt())
setBackgroundColor(0xFF1E1E1E.toInt())
textSize = 12f
}
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply {
setTextColor(0xFFBB86FC.toInt())
setBackgroundColor(0xFF1E1E1E.toInt())
textSize = 12f
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt()))
val titleId = context.resources.getIdentifier("alertTitle", "id", "android")
val titleView = dialog.findViewById<android.widget.TextView>(titleId)
titleView?.apply {
setTextColor(0xFFBB86FC.toInt())
textSize = 16f
}
}
dialog.show()
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
try {
val url = urlInput.text?.toString()?.trim() ?: ""
if (url.isNotEmpty()) {
setCustomBundleURL(context, url)
dialog.dismiss()
} else {
disableCustomBundle(context)
dialog.dismiss()
}
} catch (e: Exception) {
Toast.makeText(context, "Failed to set custom bundle: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
private fun showError(context: Context, title: String, message: String?) {
val dialog = AlertDialog.Builder(context)
.setTitle(title)
.setMessage(message ?: "An unknown error occurred")
.setPositiveButton("Close", null)
.create()
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply {
setTextColor(0xFFBB86FC.toInt())
setBackgroundColor(0xFF1E1E1E.toInt())
textSize = 12f
}
dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt()))
val titleId = context.resources.getIdentifier("alertTitle", "id", "android")
val titleView = dialog.findViewById<android.widget.TextView>(titleId)
titleView?.setTextColor(0xFFBB86FC.toInt())
val messageId = context.resources.getIdentifier("message", "id", "android")
val messageView = dialog.findViewById<android.widget.TextView>(messageId)
messageView?.setTextColor(0xFFFFFFFF.toInt())
}
dialog.show()
}
private fun clearCacheAndReset(context: Context) {
scope.launch(Dispatchers.IO) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js")
val configFile = File(pyoncordDir, "loader.json")
bundleFile.delete()
if (configFile.exists()) {
val config = JSONObject(configFile.readText())
val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
customLoadUrl.put("enabled", false)
customLoadUrl.put("url", "http://localhost:4040/gooncord.js")
config.put("customLoadUrl", customLoadUrl)
configFile.writeText(config.toString())
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "Cache cleared successfully", Toast.LENGTH_SHORT).show()
reloadApp()
}
} catch (e: Exception) {
Log.e("Error clearing cache: ${e.message}")
withContext(Dispatchers.Main) {
Toast.makeText(context, "Failed to clear cache", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun disableCustomBundle(context: Context) {
try {
val configFile = File(getPyoncordDirectory(context), "loader.json")
val config = if (configFile.exists()) {
JSONObject(configFile.readText())
} else {
JSONObject().apply {
put("loadReactDevTools", false)
}
}
val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
customLoadUrl.put("enabled", false)
customLoadUrl.put("url", "http://localhost:4040/gooncord.js")
config.put("customLoadUrl", customLoadUrl)
configFile.writeText(config.toString())
Toast.makeText(context, "Custom bundle disabled", Toast.LENGTH_SHORT).show()
reloadApp()
} catch (e: Exception) {
Toast.makeText(context, "Failed to disable custom bundle", Toast.LENGTH_SHORT).show()
}
}
private fun isSafeModeEnabled(context: Context): Boolean {
return try {
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
if (!settingsFile.exists()) return false
val json = JSONObject(settingsFile.readText())
json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false
} catch (e: Exception) {
Log.e("Error checking safe mode: ${e.message}")
false
}
}
private fun toggleSafeMode(context: Context) {
try {
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
val themeFile = File(context.filesDir, "vd_mmkv/VENDETTA_THEMES")
settingsFile.parentFile?.mkdirs()
val settings = if (settingsFile.exists()) {
JSONObject(settingsFile.readText())
} else {
JSONObject()
}
val safeMode = settings.optJSONObject("safeMode") ?: JSONObject()
val currentState = safeMode.optBoolean("enabled", false)
val newState = !currentState
safeMode.put("enabled", newState)
if (newState && themeFile.exists()) {
val theme = JSONObject(themeFile.readText())
val themeId = theme.optString("id")
if (themeId.isNotEmpty()) {
safeMode.put("currentThemeId", themeId)
themeFile.delete()
}
}
settings.put("safeMode", safeMode)
settingsFile.writeText(settings.toString())
Toast.makeText(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}", Toast.LENGTH_SHORT).show()
reloadApp()
} catch (e: Exception) {
Log.e("Error toggling safe mode: ${e.message}")
showError(context, "Failed to toggle safe mode", e.message)
}
}
private fun refetchBundle(context: Context) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js")
val backupFile = File(pyoncordDir, "bundle.js.backup")
if (bundleFile.exists()) {
backupFile.delete()
bundleFile.renameTo(backupFile)
Log.e("Bundle moved to backup")
}
reloadApp()
} catch (e: Exception) {
Log.e("Error refetching bundle: ${e.message}")
showError(context, "Failed to refetch bundle", e.message)
}
}
private fun resetBundle(context: Context) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js")
val configFile = File(pyoncordDir, "loader_config.json")
bundleFile.delete()
if (configFile.exists()) {
val config = JSONObject(configFile.readText())
config.put("customLoadUrlEnabled", false)
config.put("customLoadUrl", "http://localhost:4040/GoonCord.js")
configFile.writeText(config.toString())
}
reloadApp()
} catch (e: Exception) {
Log.e("Error resetting bundle: ${e.message}")
showError(context, "Failed to reset bundle", e.message)
}
}
private fun validateAndLoadBundle(context: Context, urlString: String) {
scope.launch(Dispatchers.IO) {
try {
val url = URL(urlString)
if (url.protocol != "http" && url.protocol != "https") {
withContext(Dispatchers.Main) {
showError(context, "Invalid URL", "URL must start with http:// or https://")
}
return@launch
}
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10000
connection.readTimeout = 10000
val responseCode = connection.responseCode
if (responseCode != 200) {
withContext(Dispatchers.Main) {
showError(context, "Error", "Server returned error $responseCode")
}
return@launch
}
val contentType = connection.contentType
if (contentType != null && !contentType.contains("javascript") && !contentType.contains("text")) {
withContext(Dispatchers.Main) {
showError(context, "Invalid Content", "URL must point to a JavaScript file")
}
return@launch
}
withContext(Dispatchers.Main) {
setCustomBundleURL(context, urlString)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showError(context, "Connection Error", "Could not reach URL: ${e.message}")
}
}
}
}
private fun setCustomBundleURL(context: Context, url: String) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val configFile = File(pyoncordDir, "loader.json")
val config = if (configFile.exists()) {
JSONObject(configFile.readText())
} else {
JSONObject().apply {
put("loadReactDevTools", false)
}
}
val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject()
customLoadUrl.put("enabled", true)
customLoadUrl.put("url", url)
config.put("customLoadUrl", customLoadUrl)
configFile.writeText(config.toString())
File(pyoncordDir, "bundle.js").delete()
Toast.makeText(context, "Custom bundle set", Toast.LENGTH_SHORT).show()
reloadApp()
} catch (e: Exception) {
Log.e("Error setting custom bundle URL: ${e.message}")
showError(context, "Failed to save configuration", e.message)
}
}
private fun getPyoncordDirectory(context: Context): File {
val dir = File(context.filesDir, "pyoncord")
if (!dir.exists()) {
dir.mkdirs()
}
return dir
}
override fun onContext(context: Context) {
}
}

View file

@ -0,0 +1,108 @@
package GoonXposed.xposed.modules
import GoonXposed.xposed.BuildConfig
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
import android.content.res.AssetManager
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.callbacks.XC_LoadPackage
import java.io.File
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule
// if needed
class PayloadGlobalModule(private val modules: List<Module>) : Module() {
private companion object {
const val GLOBAL_NAME = "__PYON_LOADER__"
}
private fun getPayloadString(): String =
JSON.encodeToString(
buildJsonObject {
put("loaderName", Constants.LOADER_NAME)
put("loaderVersion", BuildConfig.VERSION_NAME)
@Suppress("DEPRECATION") for (module in modules) module.buildPayload(this)
}
)
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
with(packageParam) {
val catalystInstance =
classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl")
val reactInstance =
classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1")
val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit =
{ param, key, json ->
runCatching {
// Attempt old CatalystInstanceImpl method
catalystInstance!!
.method(
"setGlobalVariable",
String::class.java,
String::class.java
)
.invoke(param.thisObject, key, json)
}
.onFailure {
// Failed (likely because it's a stub), use a workaround
val preloadsDir =
File(
"${appInfo.dataDir}/${Constants.FILES_DIR}",
PRELOADS_DIR
)
File(preloadsDir, "rv_globals_$key.js").apply {
writeText("this[${JSON.encodeToString(key)}]=$json")
XposedBridge.invokeOriginalMethod(
reactInstance!!.method(
"loadScriptFromFile",
String::class.java,
String::class.java,
Boolean::class.javaPrimitiveType
),
param.thisObject,
arrayOf(
absolutePath,
absolutePath,
param.args[2]
)
)
delete()
}
}
}
val hook =
MethodHookBuilder().run {
before { setGlobalVariable(param, GLOBAL_NAME, getPayloadString()) }
build()
}
listOf(catalystInstance, reactInstance).forEach { if (it != null) hook(it, hook) }
}
private fun hook(instance: Class<*>, hook: XC_MethodHook) {
instance.method(
"loadScriptFromAssets",
AssetManager::class.java,
String::class.java,
Boolean::class.javaPrimitiveType,
)
.hook(hook)
instance.method(
"loadScriptFromFile",
String::class.java,
String::class.java,
Boolean::class.javaPrimitiveType
)
.hook(hook)
}
}

View file

@ -0,0 +1,148 @@
package GoonXposed.xposed.modules
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.Utils.Log
import android.app.Activity
import android.util.AtomicFile
import androidx.core.util.writeBytes
import de.robv.android.xposed.callbacks.XC_LoadPackage
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import java.io.File
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
@Serializable data class CustomLoadUrl(val enabled: Boolean = false, val url: String = "")
@Serializable data class LoaderConfig(val customLoadUrl: CustomLoadUrl = CustomLoadUrl())
/**
* Module that updates the JS bundle by downloading it from a remote URL.
*
* Shows dialogs when failed allowing retry.
*/
class UpdaterModule : Module() {
private lateinit var config: LoaderConfig
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var error: Throwable? = null
private lateinit var cacheDir: File
private lateinit var bundle: File
private lateinit var etag: File
companion object {
var job: Job? = null
private const val TIMEOUT_CACHED = 5000L
private const val TIMEOUT = 50000L
private const val ETAG_FILE = "etag.txt"
private const val CONFIG_FILE = "loader.json"
private const val DEFAULT_BUNDLE_URL =
"https://github.com/OTKUSteyler/GoonCord/releases/download/1.1.5/goooncord.js"
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
with(packageParam) {
cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() }
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
etag = File(cacheDir, ETAG_FILE)
val configFile = File(filesDir, CONFIG_FILE)
config =
runCatching {
if (configFile.exists()) {
JSON.decodeFromString<LoaderConfig>(configFile.readText())
} else LoaderConfig()
}
.getOrDefault(LoaderConfig())
downloadScript()
}
private fun downloadScript(activity: Activity? = null) {
job =
scope.launch {
try {
HttpClient(CIO) {
expectSuccess = false
install(UserAgent) { agent = Constants.USER_AGENT }
install(HttpRedirect) {}
}
.use { client ->
val url =
config.customLoadUrl.takeIf { it.enabled }?.url
?: DEFAULT_BUNDLE_URL
Log.i("Fetching JS bundle from: $url")
val response: HttpResponse =
client.get(url) {
headers {
if (etag.exists() && bundle.exists()) {
append(
HttpHeaders.IfNoneMatch,
etag.readText()
)
}
}
// Retries don't need timeout
if (activity != null) {
var timeoutMs = TIMEOUT_CACHED
if (!bundle.exists()) {
timeoutMs = TIMEOUT
}
timeout { requestTimeoutMillis = timeoutMs }
}
}
when (response.status) {
HttpStatusCode.OK -> {
val bytes: ByteArray = response.body()
AtomicFile(bundle).writeBytes(bytes)
val newTag = response.headers[HttpHeaders.ETag]
if (!newTag.isNullOrEmpty()) etag.writeText(newTag)
else etag.delete()
Log.i("Bundle updated (${bytes.size} bytes)")
// This is a retry, so we show a dialog
if (activity != null) {
withContext(Dispatchers.Main) {}
}
}
HttpStatusCode.NotModified -> {
Log.i("Server responded with 304, no changes")
}
else -> {
throw ResponseException(
response,
"Received status: ${response.status}"
)
}
}
}
} catch (e: Throwable) {
Log.e("Failed to download script", e)
error = e
}
}
}
override fun onActivity(activity: Activity) {
error ?: return
error = null
downloadScript(activity)
}
}

View file

@ -0,0 +1,271 @@
package GoonXposed.xposed.modules.appearance
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON
import GoonXposed.xposed.Utils.Log
import android.content.res.AssetManager
import android.graphics.Typeface
import android.graphics.Typeface.CustomFallbackBuilder
import android.graphics.fonts.Font
import android.graphics.fonts.FontFamily
import android.os.Build
import de.robv.android.xposed.XC_MethodReplacement
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import java.io.File
import java.io.IOException
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.put
@Serializable
data class FontDefinition(
val name: String? = null,
val description: String? = null,
val spec: Int? = null,
val main: Map<String, String>,
)
class FontsModule : Module() {
private companion object {
val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic")
val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
const val FONTS_ASSET_PATH = "fonts/"
}
private lateinit var fontsDir: File
private lateinit var fontsDownloadsDir: File
private var fontsAbsPath: String? = null
override fun buildPayload(builder: JsonObjectBuilder) {
builder.apply { put("fontPatch", 2) }
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
with(packageParam) {
XposedHelpers.findAndHookMethod(
"com.facebook.react.views.text.ReactFontManager\$Companion",
classLoader,
"createAssetTypeface",
String::class.java,
Int::class.java,
"android.content.res.AssetManager",
object : XC_MethodReplacement() {
override fun replaceHookedMethod(param: MethodHookParam): Typeface? {
val fontFamilyName: String = param.args[0].toString()
val style: Int = param.args[1] as Int
val assetManager: AssetManager = param.args[2] as AssetManager
return createAssetTypeface(fontFamilyName, style, assetManager)
}
}
)
val fontDefFile =
File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply {
asFile()
}
if (!fontDefFile.exists()) return@with
val fontDef =
try {
JSON.decodeFromString<FontDefinition>(fontDefFile.readText())
} catch (_: Throwable) {
return@with
}
fontsDownloadsDir =
File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply {
asDir()
}
fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() }
fontsAbsPath = fontsDir.absolutePath + "/"
fontsDir.listFiles()?.forEach { file ->
val fileName = file.name
if (!fileName.startsWith(".")) {
val fontName = fileName.split('.')[0]
if (fontDef.main.keys.none { it == fontName }) {
Log.i("Deleting font file: $fileName")
file.delete()
}
}
}
// These files should be downloaded by the JS side, but oh well
CoroutineScope(Dispatchers.IO).launch {
fontDef.main
.keys
.map { name ->
async {
val url = fontDef.main.getValue(name)
try {
Log.i("Downloading $name from $url")
val file =
File(
fontsDir,
"$name${FILE_EXTENSIONS.first { url.endsWith(it) }}"
)
.apply { asFile() }
if (file.exists()) return@async
val client =
HttpClient(CIO) {
install(UserAgent) {
agent = Constants.USER_AGENT
}
}
val response: HttpResponse = client.get(url)
if (response.status == HttpStatusCode.OK)
file.writeBytes(response.body())
return@async
} catch (e: Throwable) {
Log.e("Failed to download fonts ($name from $url)", e)
}
}
}
.awaitAll()
}
}
private fun createAssetTypefaceWithFallbacks(
fontFamilyNames: Array<String>,
style: Int,
assetManager: AssetManager
): Typeface? {
val fontFamilies: MutableList<FontFamily> = ArrayList()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Iterate over the list of fontFamilyNames, constructing new FontFamily objects
// for use in the CustomFallbackBuilder below.
for (fontFamilyName in fontFamilyNames) {
try {
for (fileExtension in FILE_EXTENSIONS) {
val (customName, refName) = fontFamilyName.split(":")
val file =
File(fontsDownloadsDir, "$customName/$refName.$fileExtension")
.apply { asFile() }
val font = Font.Builder(file).build()
val family = FontFamily.Builder(font).build()
fontFamilies.add(family)
}
} catch (_: Throwable) {}
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
for (fileExtension in FILE_EXTENSIONS) {
val fileName =
StringBuilder()
.append(fontRootPath)
.append(fontFamilyName)
.append(fileExtension)
.toString()
try {
val builder =
if (fileName[0] == '/') Font.Builder(File(fileName))
else Font.Builder(assetManager, fileName)
val font = builder.build()
val family = FontFamily.Builder(font).build()
fontFamilies.add(family)
} catch (_: RuntimeException) {
// If the typeface asset does not exist, try another extension.
continue
} catch (_: IOException) {
// If the font asset does not exist, try another extension.
continue
}
}
}
}
// If there's some problem constructing fonts, fall back to the default behavior.
if (fontFamilies.isEmpty())
return createAssetTypeface(fontFamilyNames[0], style, assetManager)
val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0])
for (i in 1 until fontFamilies.size) {
fallbackBuilder.addCustomFallback(fontFamilies[i])
}
return fallbackBuilder.build()
}
return null
}
private fun createAssetTypeface(
fontFamilyName: String,
style: Int,
assetManager: AssetManager
): Typeface? {
// This logic attempts to safely check if the frontend code is attempting to use
// fallback fonts, and if it is, to use the fallback typeface creation logic.
var fontFamilyName: String = fontFamilyName
val fontFamilyNames =
fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
for (i in fontFamilyNames.indices) {
fontFamilyNames[i] = fontFamilyNames[i].trim()
}
// If there are multiple font family names:
// For newer versions of Android, construct a Typeface with fallbacks
// For older versions of Android, ignore all the fallbacks and just use the first font
// family
if (fontFamilyNames.size > 1) {
fontFamilyName =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return createAssetTypefaceWithFallbacks(
fontFamilyNames,
style,
assetManager
)
} else {
fontFamilyNames[0]
}
}
val extension = EXTENSIONS[style]
try {
for (fileExtension in FILE_EXTENSIONS) {
val (customName, refName) = fontFamilyName.split(":")
val file =
File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply {
asFile()
}
if (!file.exists()) throw Exception()
return Typeface.createFromFile(file.absolutePath)
}
} catch (_: Throwable) {}
// Lastly, after all those checks above, this is the original RN logic for
// getting the typeface.
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
for (fileExtension in FILE_EXTENSIONS) {
val fileName =
StringBuilder()
.append(fontRootPath)
.append(fontFamilyName)
.append(extension)
.append(fileExtension)
.toString()
return try {
if (fileName[0] == '/') Typeface.createFromFile(fileName)
else Typeface.createFromAsset(assetManager, fileName)
} catch (_: RuntimeException) {
// If the typeface asset does not exist, try another extension.
continue
}
}
}
return Typeface.create(fontFamilyName, style)
}
}

View file

@ -0,0 +1,45 @@
package GoonXposed.xposed.modules.appearance
import GoonXposed.xposed.Module
import android.R.color
import android.app.AndroidAppHelper
import android.content.Context
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.serialization.json.*
class SysColorsModule : Module() {
private lateinit var context: Context
private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
override fun buildPayload(builder: JsonObjectBuilder) {
context = AndroidAppHelper.currentApplication()
val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2")
val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
builder.apply {
put("isSysColorsSupported", isSupported())
if (isSupported())
putJsonObject("sysColors") {
for (accent in accents) putJsonArray(accent) {
for (shade in shades) {
val colorName = "system_" + accent + "_" + shade
val colorResourceId =
runCatching {
color::class.java.getField(colorName).getInt(null)
}
.getOrElse { 0 }
add(convertToColor(colorResourceId))
}
}
}
}
}
private fun convertToColor(id: Int): String {
val clr = if (isSupported()) ContextCompat.getColor(context, id) else 0
return String.format("#%06X", 0xFFFFFF and clr)
}
}

View file

@ -0,0 +1,160 @@
package GoonXposed.xposed.module.appearance
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Companion.JSON
import android.content.Context
import android.content.res.Resources
import androidx.core.graphics.toColorInt
import de.robv.android.xposed.callbacks.XC_LoadPackage
import java.io.File
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
@Serializable data class Author(val name: String, val id: String? = null)
@Serializable
data class ThemeData(
val name: String,
val description: String? = null,
val authors: List<Author>? = null,
val spec: Int,
val semanticColors: Map<String, List<String>>? = null,
val rawColors: Map<String, String>? = null
)
@Serializable data class Theme(val id: String, val selected: Boolean, val data: ThemeData)
class ThemesModule : Module() {
private lateinit var param: XC_LoadPackage.LoadPackageParam
private var theme: Theme? = null
private val rawColorMap = mutableMapOf<String, Int>()
private companion object {
const val THEME_FILE = "current-theme.json"
}
@ExperimentalSerializationApi
override fun buildPayload(builder: JsonObjectBuilder) {
builder.apply {
put("hasThemeSupport", true)
if (theme != null) put("storedTheme", JSON.encodeToJsonElement<Theme>(theme!!))
else put("storedTheme", null)
}
}
private fun String.fromScreamingSnakeToCamelCase() =
this.split("_").joinToString("") { it ->
it.lowercase().replaceFirstChar { it.uppercase() }
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
param = packageParam
theme = getTheme()
hookTheme()
}
private fun File.isValidish(): Boolean {
if (!this.exists()) return false
val text = this.readText()
return text.isNotBlank() && text != "{}" && text != "null"
}
private fun getTheme(): Theme? {
val themeFile =
File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply {
asFile()
}
if (!themeFile.isValidish()) return null
return try {
val themeText = themeFile.readText()
JSON.decodeFromString<Theme>(themeText)
} catch (_: Exception) {
null
}
}
fun hookTheme() {
val themeManager = param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt")
val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme")
val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme")
val theme = this.theme ?: return
// Apply rawColors
theme.data.rawColors?.forEach { (key, value) ->
rawColorMap[key.lowercase()] = hexStringToColorInt(value)
}
// Apply semanticColors
theme.data.semanticColors?.forEach { (key, value) ->
// TEXT_NORMAL -> getTextNormal
val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
value.forEachIndexed { index, v ->
when (index) {
0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v))
1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v))
}
}
}
// If there's any rawColors value, hook the color getter
if (!theme.data.rawColors.isNullOrEmpty()) {
val getColorCompat =
themeManager.getDeclaredMethod(
"getColorCompat",
Resources::class.java,
Int::class.javaPrimitiveType,
Resources.Theme::class.java,
)
val getColorCompatLegacy =
themeManager.getDeclaredMethod(
"getColorCompat",
Context::class.java,
Int::class.javaPrimitiveType
)
val patch =
MethodHookBuilder().run {
before {
val arg1 = args[0]
val resources =
if (arg1 is Context) arg1.resources else (arg1 as Resources)
val name = resources.getResourceEntryName(args[1] as Int)
if (rawColorMap[name] != null) result = rawColorMap[name]
}
build()
}
getColorCompat.hook(patch)
getColorCompatLegacy.hook(patch)
}
}
// Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB"
private fun hexStringToColorInt(hexString: String): Int {
return if (hexString.length == 9) {
// Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy
val alpha = hexString.substring(7, 9)
val rrggbb = hexString.substring(1, 7)
"#$alpha$rrggbb".toColorInt()
} else hexString.toColorInt()
}
private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
try {
themeClass.getDeclaredMethod(methodName).let { method ->
method.hook { before { result = themeValue } }
}
} catch (_: NoSuchMethodException) {}
}
}

View file

@ -0,0 +1,52 @@
package GoonXposed.xposed.modules.bridge
import GoonXposed.xposed.Module
import android.app.AlertDialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.widget.Toast
class AdditionalBridgeMethodsModule : Module() {
override fun onContext(context: Context) =
with(context) {
BridgeModule.registerMethod("revenge.alertError") {
val (error, version) = it
val app = getAppInfo()
val errorString = "$error"
val clipboard =
applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as
ClipboardManager
val clip = ClipData.newPlainText("Stack Trace", errorString)
AlertDialog.Builder(this)
.setTitle("GoonXposed Error")
.setMessage(
"""
GoonXposed: $version
${app.name}: ${app.version} (${app.versionCode})
Device: ${Build.MANUFACTURER} ${Build.MODEL}
""".trimIndent() +
errorString
)
.setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
.setNeutralButton("Copy") { dialog, _ ->
clipboard.setPrimaryClip(clip)
Toast.makeText(
applicationContext,
"Copied stack trace",
Toast.LENGTH_SHORT
)
.show()
dialog.dismiss()
}
.show()
null
}
}
}

View file

@ -0,0 +1,208 @@
package GoonXposed.xposed.modules.bridge
import GoonXposed.xposed.BuildConfig
import GoonXposed.xposed.Constants
import GoonXposed.xposed.Module
import GoonXposed.xposed.Utils.Log
import de.robv.android.xposed.callbacks.XC_LoadPackage
import java.io.File
import java.lang.reflect.Method
/**
* See for possible return types:
* https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19
*
* You may return a [Unit] and the resulting value will be `null`.
*/
typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any?
typealias BridgeMethodArgs = ArrayList<Any>
/**
* A module that exposes a bridge for calling methods from JavaScript.
*
* To call a method, pass an object with the following structure to a hooked method:
* ```js
* {
* revenge: {
* method: "method.name",
* args: [arg1, arg2, ...]
* }
* }
* ```
*
* For methods that accept additional arguments, only the first object argument is used for the
* bridge call.
*
* The result will be an object with either a `result` or `error` key:
* ```js
* {
* result: ... // The return value of the method
* }
* ```
* or
* ```js
* {
* error: "Error message"
* }
* ```
*/
class BridgeModule : Module() {
private lateinit var readableMapGetString: Method
private lateinit var readableMapToHashMapMethod: Method
private lateinit var argumentsMakeNative: Method
companion object {
private const val CALL_DATA_KEY = "revenge"
private const val METHOD_NAME_KEY = "method"
private const val METHOD_ARGS_KEY = "args"
private fun File.openFileGuarded() {
if (!this.exists()) throw Error("Path does not exist: $path")
if (!this.isFile) throw Error("Path is not a file: $path")
}
private val methods: MutableMap<String, BridgeMethodCallback> =
mutableMapOf(
"revenge.info" to
{
mapOf(
"name" to Constants.LOADER_NAME,
"version" to BuildConfig.VERSION_CODE
)
},
"revenge.test" to
{
mapOf(
"string" to "string",
"number" to 7256,
"array" to listOf("testing", 527737, listOf(true)),
"object" to mapOf("nested" to true),
"boolean" to false,
"args" to it,
)
},
"revenge.fs.delete" to
{
val (path) = it
File(path as String).run {
if (this.isDirectory) this.deleteRecursively()
else this.delete()
}
},
"revenge.fs.exists" to
{
val (path) = it
File(path as String).exists()
},
"revenge.fs.read" to
{ it ->
val (path) = it
val file = File(path as String).apply { openFileGuarded() }
file.bufferedReader().use { it.readText() }
},
"revenge.fs.write" to
{ it ->
val (path, contents) = it
val file = File(path as String).apply { openFileGuarded() }
file.writeText(contents as String)
}
)
/**
* Registers a bridge method that can be called from JavaScript.
*
* If the method is already registered, it will be overwritten.
*
* @param name The name of the method to register.
* @param callback The callback to invoke when the method is called.
*/
fun registerMethod(name: String, callback: BridgeMethodCallback) {
if (methods.containsKey(name))
Log.w("Bridge method already exists and will be overridden: $name")
methods[name] = callback
}
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
with(packageParam) {
val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments")
val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap")
val promise = classLoader.loadClass("com.facebook.react.bridge.Promise")
val promiseResolve = promise.method("resolve", Object::class.java)
argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java)
readableMapGetString = readableMap.method("getString", String::class.java)
readableMapToHashMapMethod = readableMap.method("toHashMap")
classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager").hookMethod(
"getBBox",
Double::class.javaObjectType,
readableMap
) {
before {
callBridgeMethod(readableMapToHashMapConverter(args[1]!!))?.let {
result = it.toNativeObject()
}
}
}
classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule").hookMethod(
"readAsDataURL",
readableMap,
promise
) {
before {
// Avoid shadowing the outer `readableMap` and `promise` variables
val (readableMapArg, promiseArg) = args
callBridgeMethod(readableMapToHashMapConverter(readableMapArg!!))?.let {
promiseResolve.invoke(promiseArg, it.toNativeObject())
result = null
}
}
}
}
}
private fun Any?.toNativeObject(): Any? =
argumentsMakeNative.invoke(
null,
when (this) {
Unit -> null
else -> this
}
)
private fun readableMapToHashMapConverter(map: Any): HashMap<String, Any?> {
@Suppress("UNCHECKED_CAST")
return readableMapToHashMapMethod.invoke(map) as HashMap<String, Any?>
}
private fun callBridgeMethod(hashMap: HashMap<String, Any?>): Map<String, Any?>? {
try {
val pair = getBridgeCallData(hashMap) ?: return null
val (method, args) = pair
val ret = method(args).toNativeObject()
return mapOf("result" to ret)
} catch (e: Throwable) {
return mapOf("error" to e.toString())
}
}
private fun getBridgeCallData(
hashMap: HashMap<String, Any?>
): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
@Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
data ?: return null
@Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String
val method = methods[name]
method ?: throw Error("Method not registered: $name")
@Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs
return Pair(method, args)
}
}

View file

@ -21,5 +21,3 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
# Suppress deprecation warning when building with a newer JDK (e.g., Java 21)
android.javaCompile.suppressSourceTargetDeprecationWarning=true

View file

@ -1,2 +1,2 @@
rootProject.name = "ShiggyXposed" rootProject.name = "KettuXposed"
include(":app") include(":app")