diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8fabe0f..b37574a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,52 +2,52 @@ name: Build APK
on: [push]
jobs:
- build:
- name: Build and push
- runs-on: ubuntu-latest
+ build:
+ name: Build and push
+ runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
+ steps:
+ - uses: actions/checkout@v3
- - uses: actions/setup-java@v3
- with:
- distribution: "temurin"
- java-version: "17"
+ - uses: actions/setup-java@v3
+ with:
+ distribution: "temurin"
+ java-version: "17"
- - name: "Setup Gradle"
- uses: gradle/gradle-build-action@v2
+ - name: "Setup Gradle"
+ uses: gradle/gradle-build-action@v2
- - name: "Grant execution permission"
- run: chmod +x ./gradlew
+ - name: "Grant execution permission"
+ run: chmod +x ./gradlew
- - name: "Build APKs"
- run: ./gradlew build
+ - name: "Build APKs"
+ run: ./gradlew build
- - name: "Setup directories"
- run: |
- mkdir -p /tmp/pupubuild/apks
- mkdir -p /tmp/pupubuild/tools
+ - name: "Setup directories"
+ run: |
+ mkdir -p /tmp/ShiggyXposedbuild/apks
+ mkdir -p /tmp/ShiggyXposedbuild/tools
- - name: "Move debug APK"
- run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/pupubuild/apks
+ - name: "Move debug APK"
+ run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/ShiggyXposedbuild/apks
- - 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/pupubuild/tools/uber-apk-signer.jar
+ - 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
- - name: "Sign release APK"
- run: java -jar /tmp/pupubuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/pupubuild/apks/
+ - 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/
- - name: "Rename release APK"
- run: mv /tmp/pupubuild/apks/app-release-aligned-debugSigned.apk /tmp/pupubuild/apks/app-release.apk
+ - name: "Rename release APK"
+ run: mv /tmp/ShiggyXposedbuild/apks/app-release-aligned-debugSigned.apk /tmp/ShiggyXposedbuild/apks/app-release.apk
- - name: "Upload debug APK"
- uses: actions/upload-artifact@v4
- with:
- name: app-debug
- path: /tmp/pupubuild/apks/app-debug.apk
+ - name: "Upload debug APK"
+ uses: actions/upload-artifact@v4
+ with:
+ name: app-debug
+ path: /tmp/ShiggyXposedbuild/apks/app-debug.apk
- - name: "Upload release APK"
- uses: actions/upload-artifact@v4
- with:
- name: app-release
- path: /tmp/pupubuild/apks/app-release.apk
\ No newline at end of file
+ - name: "Upload release APK"
+ uses: actions/upload-artifact@v4
+ with:
+ name: app-release
+ path: /tmp/ShiggyXposedbuild/apks/app-release.apk
diff --git a/.gitignore b/.gitignore
index 0598b79..162ad5d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,5 @@ build
.externalNativeBuild
.cxx
local.properties
-.idea/AndroidProjectSystem.xml
-/app/release
-/app/build
\ No newline at end of file
+app/release/release/
+release
diff --git a/.idea/.name b/.idea/.name
index 29d110d..e551d4f 100644
--- a/.idea/.name
+++ b/.idea/.name
@@ -1 +1 @@
-KettuXposed
\ No newline at end of file
+ShiggyXposed
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
deleted file mode 100644
index 371f2e2..0000000
--- a/.idea/appInsightsSettings.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 7643783..0def1f7 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -1,121 +1,10 @@
+
+
-
-
-
-
-
-
-
-
-
-
-
- xmlns:android
-
- ^$
-
-
-
-
-
-
-
-
- xmlns:.*
-
- ^$
-
-
- BY_NAME
-
-
-
-
-
-
- .*:id
-
- http://schemas.android.com/apk/res/android
-
-
-
-
-
-
-
-
- .*:name
-
- http://schemas.android.com/apk/res/android
-
-
-
-
-
-
-
-
- name
-
- ^$
-
-
-
-
-
-
-
-
- style
-
- ^$
-
-
-
-
-
-
-
-
- .*
-
- ^$
-
-
- BY_NAME
-
-
-
-
-
-
- .*
-
- http://schemas.android.com/apk/res/android
-
-
- ANDROID_ATTRIBUTE_ORDER
-
-
-
-
-
-
- .*
-
- .*
-
-
- BY_NAME
-
-
-
-
-
diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml
deleted file mode 100644
index 0c0c338..0000000
--- a/.idea/deploymentTargetDropDown.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index 38bacf1..b268ef3 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,14 +4,6 @@
-
-
-
-
-
-
-
-
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 7b3006b..639c779 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -13,7 +13,6 @@
-
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
deleted file mode 100644
index e34606c..0000000
--- a/.idea/jarRepositories.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index c22b6fa..0000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index b2c751a..2557637 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,9 +1,7 @@
-
-
-
-
-
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 35eb1dd..94a25f7 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 0e14d8e..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "java.configuration.updateBuildConfiguration": "disabled"
-}
\ No newline at end of file
diff --git a/Images/kettu.png b/Images/kettu.png
deleted file mode 100644
index c047af7..0000000
Binary files a/Images/kettu.png and /dev/null differ
diff --git a/Images/pupu_logo.png b/Images/pupu_logo.png
deleted file mode 100644
index babfe18..0000000
Binary files a/Images/pupu_logo.png and /dev/null differ
diff --git a/README.md b/README.md
deleted file mode 100644
index 0451a8f..0000000
--- a/README.md
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-

-
- # KettuXposed
-
- Easily install Kettu on your rooted device!
-
- [](https://github.com/C0C0B01/KettuXposed/releases/latest)
-
- ---
-
-
-
- &color=blue)
- &color=blue)
-
-
-
-
-
-## Contributing
-
-This is an open-source project, you can do so without any programming.
-
-Here are a few things you can do:
-
-- [Test and report issues](https://github.com/C0C0B01/KettuXposed/issues/new/choose)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cdebb75..46af0dd 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -5,15 +5,15 @@ plugins {
}
android {
- namespace = "cocobo1.pupu.xposed"
+ namespace = "ShiggyXposed.xposed"
compileSdk = 36
defaultConfig {
- applicationId = "cocobo1.pupu.xposed"
+ applicationId = "ShiggyXposed.xposed"
minSdk = 22
targetSdk = 36
- versionCode = 1130
- versionName = "1.1.3"
+ versionCode = 1000
+ versionName = "1.0.0"
}
buildTypes {
@@ -29,41 +29,30 @@ android {
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
- targetCompatibility = JavaVersion.VERSION_21
- }
- kotlinOptions {
- jvmTarget = "21"
- }
- buildFeatures {
- buildConfig = true
+ targetCompatibility = JavaVersion.VERSION_17
}
+ kotlinOptions { jvmTarget = "17" }
+ buildFeatures { buildConfig = true }
}
-repositories {
- maven("https://jitpack.io")
+repositories {
+ mavenCentral()
+ google()
+ maven("https://jitpack.io")
}
dependencies {
compileOnly("de.robv.android.xposed:api:82")
- compileOnly(libs.xposed)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
- implementation(libs.libunbound)
- implementation(libs.kotlinx.serialization.json)
- implementation(libs.kotlinx.coroutines.android)
- implementation(libs.ktor.client.core)
- implementation(libs.ktor.client.cio)
- implementation(libs.kotlin.reflect)
-
- implementation(libs.yuki.api)
-
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.appcompat)
- implementation(libs.material)
-
- testImplementation(libs.junit)
-
- androidTestImplementation(libs.androidx.junit)
- androidTestImplementation(libs.androidx.espresso.core)
+ implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0")
+ implementation("io.ktor:ktor-client-core:3.3.0")
+ implementation("io.ktor:ktor-client-cio:3.3.0")
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("com.google.android.material:material:1.11.0")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.5")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f830c6d..0c1bdf6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,25 +1,25 @@
+ xmlns:tools="http://schemas.android.com/tools">
+ android:name="xposedmodule"
+ android:value="true"/>
+ android:name="xposeddescription"
+ android:value="Xposed module to inject ShiggyCord into discord"/>
+ android:name="xposedminversion"
+ android:value="82"/>
-
\ No newline at end of file
+
diff --git a/app/src/main/assets/xposed_init b/app/src/main/assets/xposed_init
index 572cbc0..36c4f0c 100644
--- a/app/src/main/assets/xposed_init
+++ b/app/src/main/assets/xposed_init
@@ -1 +1 @@
-cocobo1.pupu.xposed.Main
\ No newline at end of file
+ShiggyXposed.xposed.Main
diff --git a/app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt
deleted file mode 100644
index 0bd107e..0000000
--- a/app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt
+++ /dev/null
@@ -1,258 +0,0 @@
-// credits to janisslsm from his PR: https://github.com/vendetta-mod/VendettaXposed/pull/17
-// hooks are modified function from RN codebase
-
-package cocobo1.pupu.xposed
-
-import android.content.res.AssetManager
-import android.os.Build
-import android.graphics.Typeface
-import android.graphics.Typeface.CustomFallbackBuilder
-import android.graphics.fonts.Font
-import android.graphics.fonts.FontFamily
-import android.util.Log
-import de.robv.android.xposed.XC_MethodReplacement
-import de.robv.android.xposed.XposedHelpers
-import de.robv.android.xposed.callbacks.XC_LoadPackage
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.*
-import java.io.IOException
-import java.io.File
-import kotlinx.coroutines.*
-
-import io.ktor.client.*
-import io.ktor.client.call.*
-import io.ktor.client.request.*
-import io.ktor.client.engine.cio.*
-import io.ktor.client.statement.*
-import io.ktor.client.plugins.*
-import io.ktor.http.*
-
-@Serializable
-data class FontDefinition(
- val name: String? = null,
- val description: String? = null,
- val spec: Int? = null,
- val main: Map,
-)
-
-class FontsModule: Module() {
- private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic")
- private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
- private val FONTS_ASSET_PATH = "fonts/"
-
- private lateinit var fontsDir: File
- private lateinit var fontsDownloadsDir: File
- private var fontsAbsPath: String? = null
-
- override fun buildJson(builder: JsonObjectBuilder) {
- builder.apply {
- put("fontPatch", 2)
- }
- }
-
- private fun hookClass(classLoader: ClassLoader, className: String) {
- XposedHelpers.findAndHookMethod(
- className,
- 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)
- }
- })
- }
-
- override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) {
- try {
- // Try to hook on versions (280201+)
- hookClass(classLoader, "com.facebook.react.views.text.ReactFontManager\$Companion")
- } catch (e: Throwable) {
- when (e) {
- // Hook old class (280200-)
- is NoClassDefFoundError, is XposedHelpers.ClassNotFoundError -> hookClass(classLoader, "com.facebook.react.views.text.ReactFontManager")
- else -> throw e
- }
- }
-
- val fontDefFile = File(appInfo.dataDir, "files/pyoncord/fonts.json")
- if (!fontDefFile.exists()) return@with
-
- val fontDef = try {
- val json = Json { ignoreUnknownKeys = true }
- json.decodeFromString(fontDefFile.readText())
- } catch (_: Throwable) { return@with }
-
- fontsDownloadsDir = File(appInfo.dataDir, "files/pyoncord/downloads/fonts").apply { mkdirs() }
- fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { mkdirs() }
- 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("Kettu", "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("Kettu", "Downloading $name from $url")
- val file = File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}")
- if (file.exists()) return@async
-
- val client = HttpClient(CIO) {
- install(UserAgent) { agent = "KettuXposed" }
- }
-
- val response: HttpResponse = client.get(url)
-
- if (response.status == HttpStatusCode.OK) {
- file.writeBytes(response.body())
- }
-
- return@async
- } catch (e: Throwable) {
- Log.e("Kettu", "Failed to download fonts ($name from $url)", e)
- }
- }
- }.awaitAll()
- }
-
- return@with
- }
-
- private fun createAssetTypefaceWithFallbacks(
- fontFamilyNames: Array,
- style: Int,
- assetManager: AssetManager
- ): Typeface? {
- val fontFamilies: MutableList = ArrayList()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- // Iterate over the list of fontFamilyNames, constructing new FontFamily objects
- // for use in the CustomFallbackBuilder below.
- for (fontFamilyName in fontFamilyNames) {
- try {
- for (fileExtension in FILE_EXTENSIONS) {
- val (customName, refName) = fontFamilyName.split(":")
- val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension")
- val font = Font.Builder(file).build()
- val family = FontFamily.Builder(font).build()
- fontFamilies.add(family)
- }
- } catch (e: Throwable) {
- // ignore
- }
-
- for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
- for (fileExtension in FILE_EXTENSIONS) {
- val fileName = java.lang.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 (e: java.lang.RuntimeException) {
- // If the typeface asset does not exist, try another extension.
- continue
- } catch (e: 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.size == 0) {
- 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")
- if (!file.exists()) throw Exception()
- return Typeface.createFromFile(file.absolutePath)
- }
- } catch (e: Throwable) {
- // ignore
- }
-
- // 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 = java.lang.StringBuilder()
- .append(fontRootPath)
- .append(fontFamilyName)
- .append(extension)
- .append(fileExtension)
- .toString()
-
- return try {
- if (fileName[0] == '/')
- Typeface.createFromFile(fileName)
- else
- Typeface.createFromAsset(assetManager, fileName)
- } catch (e: java.lang.RuntimeException) {
- // If the typeface asset does not exist, try another extension.
- continue
- }
- }
- }
-
- return Typeface.create(fontFamilyName, style)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/io/github/pupu/xposed/LogBoxModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/LogBoxModule.kt
deleted file mode 100644
index e804f7e..0000000
--- a/app/src/main/kotlin/io/github/pupu/xposed/LogBoxModule.kt
+++ /dev/null
@@ -1,117 +0,0 @@
-package cocobo1.pupu.xposed
-
-import android.app.AlertDialog
-import android.app.AndroidAppHelper
-import android.content.Context
-import android.content.Intent
-import android.util.Log
-import de.robv.android.xposed.XC_MethodHook
-import de.robv.android.xposed.XposedBridge
-import de.robv.android.xposed.XposedHelpers
-import de.robv.android.xposed.callbacks.XC_LoadPackage
-import java.io.File
-import kotlin.system.exitProcess
-
-class LogBoxModule: Module() {
- lateinit var packageParam: XC_LoadPackage.LoadPackageParam
-
- override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) {
- this@LogBoxModule.packageParam = packageParam
-
- val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
- val bridgeDevSupportManagerClass = classLoader.loadClass("com.facebook.react.devsupport.BridgeDevSupportManager")
-
- val mReactInstanceDevHelperField = XposedHelpers.findField(bridgeDevSupportManagerClass, "mReactInstanceDevHelper")
-
- val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
- val handleReloadJSMethod = bridgeDevSupportManagerClass.methods.first { it.name == "handleReloadJS" }
- val showDevOptionsDialogMethod = bridgeDevSupportManagerClass.methods.first { it.name == "showDevOptionsDialog" }
-
- var alertDialog: AlertDialog? = null
-
- // This enables the LogBox and opens dev option on shake
- XposedBridge.hookMethod(
- getUseDeveloperSupportMethod,
- object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- param.result = true
- }
- }
- )
-
- // Triggered on shake
- XposedBridge.hookMethod(
- showDevOptionsDialogMethod,
- object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- try {
- if (alertDialog == null) {
- val mReactInstanceDevHelper =
- mReactInstanceDevHelperField.get(param.thisObject)
- val getCurrentActivityMethod =
- mReactInstanceDevHelper.javaClass.methods.first { it.name == "getCurrentActivity" }
-
- val context =
- getCurrentActivityMethod.invoke(mReactInstanceDevHelper) as Context
-
- alertDialog = showRecoveryAlert(context) {
- alertDialog = null
- }
- }
- } catch (ex: Exception) {
- Log.e("Kettu", "Failed to show dev options dialog: $ex")
- alertDialog = null
- }
-
- // Ignore the original dev menu
- param.result = null
- }
- }
- )
-
- // Replace the method to direct relaunch the app instead of sending reload command to developer server
- XposedBridge.hookMethod(
- handleReloadJSMethod,
- object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- reloadApp()
- }
- }
- )
-
- return@with
- }
-
- private fun showRecoveryAlert(context: Context, onClose: () -> Unit): AlertDialog {
- return AlertDialog.Builder(context)
- .setTitle("Kettu Recovery Options")
- .setItems(arrayOf("Reload", "Delete bundle.js")) { _, which ->
- when (which) {
- 0 -> {
- reloadApp()
- }
- 1 -> {
- val bundleFile = File(packageParam.appInfo.dataDir, "cache/pyoncord/bundle.js")
- if (bundleFile.exists()) {
- bundleFile.delete()
- }
-
- reloadApp()
- }
- }
-
- onClose()
- }
- .setOnDismissListener {
- onClose()
- }
- .show()
- }
-
- private fun reloadApp() {
- val application = AndroidAppHelper.currentApplication()
- val intent = application.packageManager.getLaunchIntentForPackage(application.packageName)
- application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
- exitProcess(0)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/io/github/pupu/xposed/Main.kt b/app/src/main/kotlin/io/github/pupu/xposed/Main.kt
deleted file mode 100644
index 769f8e7..0000000
--- a/app/src/main/kotlin/io/github/pupu/xposed/Main.kt
+++ /dev/null
@@ -1,230 +0,0 @@
-package cocobo1.pupu.xposed
-
-import android.app.Activity
-import android.content.res.AssetManager
-import android.content.res.Resources
-import android.util.Log
-import android.os.Bundle
-import android.widget.Toast
-import de.robv.android.xposed.IXposedHookLoadPackage
-import de.robv.android.xposed.XC_MethodHook
-import de.robv.android.xposed.XposedBridge
-import de.robv.android.xposed.callbacks.XC_LoadPackage
-import io.ktor.client.*
-import io.ktor.client.call.*
-import io.ktor.client.request.*
-import io.ktor.client.engine.cio.*
-import io.ktor.client.statement.*
-import io.ktor.client.plugins.*
-import io.ktor.http.*
-import kotlinx.coroutines.*
-import kotlinx.serialization.*
-import kotlinx.serialization.json.*
-import java.io.File
-
-@Serializable
-data class CustomLoadUrl(
- val enabled: Boolean,
- val url: String
-)
-
-@Serializable
-data class LoaderConfig(
- val customLoadUrl: CustomLoadUrl
-)
-
-class Main : IXposedHookLoadPackage {
- private val Modules: Array = arrayOf(
- ThemeModule(),
- SysColorsModule(),
- FontsModule(),
- LogBoxModule()
- )
-
- fun buildLoaderJsonString(): String {
- val obj = buildJsonObject {
- put("loaderName", "KettuXposed")
- put("loaderVersion", BuildConfig.VERSION_NAME)
-
- for (module in Modules) {
- module.buildJson(this)
- }
- }
-
- return Json.encodeToString(obj)
- }
-
- override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
- val reactActivity = runCatching {
- lpparam.classLoader.loadClass("com.discord.react_activities.ReactActivity")
- }.getOrElse { return } // Package is not our the target app, return
-
- var activity: Activity? = null
- val onActivityCreateCallback = mutableSetOf<(activity: Activity) -> Unit>()
-
- XposedBridge.hookMethod(reactActivity.getDeclaredMethod("onCreate", Bundle::class.java), object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- activity = param.thisObject as Activity
- onActivityCreateCallback.forEach { cb -> cb(activity!!) }
- onActivityCreateCallback.clear()
- }
- })
-
- init(lpparam) { cb ->
- if (activity != null) cb(activity!!)
- else onActivityCreateCallback.add(cb)
- }
- }
-
- private fun init(
- param: XC_LoadPackage.LoadPackageParam,
- onActivityCreate: ((activity: Activity) -> Unit) -> Unit
- ) = with (param) {
- val catalystInstanceImpl = classLoader.loadClass("com.facebook.react.bridge.CatalystInstanceImpl")
-
- for (module in Modules) module.onInit(param)
-
- val loadScriptFromAssets = catalystInstanceImpl.getDeclaredMethod(
- "loadScriptFromAssets",
- AssetManager::class.java,
- String::class.java,
- Boolean::class.javaPrimitiveType
- ).apply { isAccessible = true }
-
- val loadScriptFromFile = catalystInstanceImpl.getDeclaredMethod(
- "loadScriptFromFile",
- String::class.java,
- String::class.java,
- Boolean::class.javaPrimitiveType
- ).apply { isAccessible = true }
-
- val setGlobalVariable = catalystInstanceImpl.getDeclaredMethod(
- "setGlobalVariable",
- String::class.java,
- String::class.java
- ).apply { isAccessible = true }
-
- val cacheDir = File(appInfo.dataDir, "cache/pyoncord").apply { mkdirs() }
- val filesDir = File(appInfo.dataDir, "files/pyoncord").apply { mkdirs() }
-
- val preloadsDir = File(filesDir, "preloads").apply { mkdirs() }
- val bundle = File(cacheDir, "bundle.js")
- val etag = File(cacheDir, "etag.txt")
-
- val configFile = File(filesDir, "loader.json")
-
- val config = try {
- if (!configFile.exists()) throw Exception()
- val json = Json { ignoreUnknownKeys = true }
- json.decodeFromString(configFile.readText())
- } catch (_: Exception) {
- LoaderConfig(
- customLoadUrl = CustomLoadUrl(
- enabled = false,
- url = "" // Not used
- )
- )
- }
-
- val scope = MainScope()
- val httpJob = scope.async(Dispatchers.IO) {
- try {
- val client = HttpClient(CIO) {
- expectSuccess = true
- install(HttpTimeout) {
- requestTimeoutMillis = if (bundle.exists()) 4000 else 20000
- }
- install(UserAgent) { agent = "KettuXposed" }
- }
-
- val url =
- if (config.customLoadUrl.enabled) config.customLoadUrl.url
- else "https://raw.githubusercontent.com/C0C0B01/Kettu/refs/heads/dist/kettu.min.js"
-
- Log.e("Kettu", "Fetching JS bundle from $url")
-
- val response: HttpResponse = client.get(url) {
- headers {
- if (etag.exists() && bundle.exists()) {
- append(HttpHeaders.IfNoneMatch, etag.readText())
- }
- }
- }
-
- bundle.writeBytes(response.body())
- if (response.headers["Etag"] != null) {
- etag.writeText(response.headers["Etag"]!!)
- }
- else if (etag.exists()) {
- // This is called when server does not return an E-tag, so clear em
- etag.delete()
- }
-
- return@async
- } catch (e: RedirectResponseException) {
- if (e.response.status != HttpStatusCode.NotModified) throw e
- Log.e("Kettu", "Server responded with status code 304 - no changes to file")
- } catch (e: Throwable) {
- onActivityCreate { activity ->
- activity.runOnUiThread {
- Toast.makeText(
- activity.applicationContext,
- "Failed to fetch JS bundle, Kettu may not load!",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
-
- Log.e("Kettu", "Failed to download bundle", e)
- }
- }
-
- val patch = object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- runBlocking { httpJob.join() }
-
- XposedBridge.invokeOriginalMethod(
- setGlobalVariable,
- param.thisObject,
- arrayOf("__PYON_LOADER__", buildLoaderJsonString())
- )
-
- preloadsDir
- .walk()
- .filter { it.isFile && it.extension == "js" }
- .forEach { file ->
- XposedBridge.invokeOriginalMethod(
- loadScriptFromFile,
- param.thisObject,
- arrayOf(file.absolutePath, file.absolutePath, param.args[2])
- )
- }
-
- XposedBridge.invokeOriginalMethod(
- loadScriptFromFile,
- param.thisObject,
- arrayOf(bundle.absolutePath, bundle.absolutePath, param.args[2])
- )
- }
- }
-
- XposedBridge.hookMethod(loadScriptFromAssets, patch)
- XposedBridge.hookMethod(loadScriptFromFile, patch)
-
- // Fighting the side effects of changing the package name
- if (packageName != "com.discord") {
- val getIdentifier = Resources::class.java.getDeclaredMethod(
- "getIdentifier",
- String::class.java,
- String::class.java,
- String::class.java
- )
-
- XposedBridge.hookMethod(getIdentifier, object: XC_MethodHook() {
- override fun beforeHookedMethod(mhparam: MethodHookParam) = with(mhparam) {
- if (args[2] == param.packageName) args[2] = "com.discord"
- }
- })
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/io/github/pupu/xposed/PupuModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/PupuModule.kt
deleted file mode 100644
index 139b7ea..0000000
--- a/app/src/main/kotlin/io/github/pupu/xposed/PupuModule.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package cocobo1.pupu.xposed
-
-import de.robv.android.xposed.callbacks.XC_LoadPackage
-import kotlinx.serialization.json.JsonObjectBuilder
-
-abstract class Module {
- open fun buildJson(builder: JsonObjectBuilder) {}
- open fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) {}
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/io/github/pupu/xposed/SysColorsModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/SysColorsModule.kt
deleted file mode 100644
index 4f400a8..0000000
--- a/app/src/main/kotlin/io/github/pupu/xposed/SysColorsModule.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package cocobo1.pupu.xposed
-
-import android.app.AndroidAppHelper
-import android.content.Context
-import android.os.Build
-import androidx.core.content.ContextCompat
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.*
-
-@Serializable
-data class SysColors(
- val neutral1: List,
- val neutral2: List,
- val accent1: List,
- val accent2: List,
- val accent3: List
-)
-
-class SysColorsModule : Module() {
- private lateinit var context: Context
- private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
-
- override fun buildJson(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 {
- android.R.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)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt
deleted file mode 100644
index 3caf491..0000000
--- a/app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt
+++ /dev/null
@@ -1,165 +0,0 @@
-package cocobo1.pupu.xposed
-
-import android.content.Context
-import android.graphics.Color
-import android.content.res.Resources
-import de.robv.android.xposed.XC_MethodHook
-import de.robv.android.xposed.XposedBridge
-import de.robv.android.xposed.callbacks.XC_LoadPackage
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.decodeFromString
-import kotlinx.serialization.encodeToString
-import kotlinx.serialization.json.Json
-import java.io.File
-import kotlinx.serialization.json.*
-
-@Serializable
-data class Author(
- val name: String,
- val id: String? = null
-)
-@Serializable
-data class ThemeData(
- val name: String,
- val description: String? = null,
- val authors: List? = null,
- val spec: Int,
- val semanticColors: Map>? = null,
- val rawColors: Map? = null
-)
-@Serializable
-data class Theme(
- val id: String,
- val selected: Boolean,
- val data: ThemeData
-)
-
-class ThemeModule : Module() {
- private lateinit var param: XC_LoadPackage.LoadPackageParam
-
- private var theme: Theme? = null
- private val rawColorMap = mutableMapOf()
-
- @ExperimentalSerializationApi
- override fun buildJson(builder: JsonObjectBuilder) {
- builder.apply {
- put("hasThemeSupport", true)
- if (theme != null)
- put("storedTheme", Json.encodeToJsonElement(theme!!))
- else
- put("storedTheme", null)
- }
- }
-
- override fun onInit(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 filesDir = File(param.appInfo.dataDir, "files").apply { mkdirs() }
- val pyonDir = File(filesDir, "pyoncord").apply { mkdirs() }
- val themeFile = File(pyonDir, "current-theme.json")
-
- val legacyThemeFile = File(filesDir, "vendetta_theme.json")
- if (legacyThemeFile.isValidish() && !themeFile.isValidish()) {
- legacyThemeFile.copyTo(themeFile, overwrite = true)
- }
-
- if (!themeFile.isValidish()) return null
-
- return try {
- val themeText = themeFile.readText()
- Json { ignoreUnknownKeys = true }.decodeFromString(themeText)
- } catch (e: 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
- if (theme == null) 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.split("_").joinToString("") { it.lowercase().replaceFirstChar { it.uppercase() } }}"
- 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 = object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- val arg1 = param.args[0]
- val resources = if (arg1 is Context) arg1.resources else (arg1 as Resources)
- val name = resources.getResourceEntryName(param.args[1] as Int)
-
- if (rawColorMap[name] != null) param.result = rawColorMap[name]
- }
- }
-
- XposedBridge.hookMethod(getColorCompat, patch)
- XposedBridge.hookMethod(getColorCompatLegacy, 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)
- Color.parseColor("#$alpha$rrggbb")
- } else Color.parseColor(hexString)
- }
-
- private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
- try {
- themeClass.getDeclaredMethod(methodName).let { method ->
- // Log.i("Hooking $methodName -> ${themeValue.toString(16)}")
- XposedBridge.hookMethod(method, object : XC_MethodHook() {
- override fun beforeHookedMethod(param: MethodHookParam) {
- param.result = themeValue
- }
- })
- }
- } catch (ex: NoSuchMethodException) {
- // do nothing
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt b/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt
new file mode 100644
index 0000000..86245e2
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt
@@ -0,0 +1,18 @@
+package ShiggyXposed.xposed
+
+class Constants {
+ companion object {
+ const val TARGET_PACKAGE = "com.discord"
+ const val TARGET_ACTIVITY = "$TARGET_PACKAGE.react_activities.ReactActivity"
+
+ const val FILES_DIR = "files/pyoncord"
+ const val CACHE_DIR = "cache/pyoncord"
+ const val MAIN_SCRIPT_FILE = "bundle.js"
+
+ const val LOG_TAG = "ShiggyXposed"
+
+ const val LOADER_NAME = "ShiggyXposed"
+
+ const val USER_AGENT = "ShiggyXposed"
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Main.kt b/app/src/main/kotlin/io/github/revenge/xposed/Main.kt
new file mode 100644
index 0000000..38118cc
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/Main.kt
@@ -0,0 +1,70 @@
+package ShiggyXposed.xposed
+
+import ShiggyXposed.xposed.modules.*
+import ShiggyXposed.xposed.modules.appearance.FontsModule
+import ShiggyXposed.xposed.modules.appearance.SysColorsModule
+import ShiggyXposed.xposed.modules.appearance.ThemesModule
+import ShiggyXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule
+import ShiggyXposed.xposed.modules.bridge.BridgeModule
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.os.Bundle
+import de.robv.android.xposed.IXposedHookLoadPackage
+import de.robv.android.xposed.IXposedHookZygoteInit
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+
+class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
+ private var hooked = false
+ private val modules =
+ mutableListOf(
+ HookScriptLoaderModule(),
+ BridgeModule(),
+ AdditionalBridgeMethodsModule(),
+ UpdaterModule(),
+ FixResourcesModule(),
+ LogBoxModule(),
+ FontsModule(),
+ ThemesModule(),
+ SysColorsModule()
+ )
+
+ init {
+ modules.add(PayloadGlobalModule(modules))
+ }
+
+ override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) {
+ for (module in modules) module.onInit(startupParam)
+ }
+
+ override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) =
+ with(param) {
+ if (hooked) return
+
+ val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY)
+
+ ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) {
+ after { this@Main.onContext(args[0] as Context) }
+ }
+
+ reactActivity.hookMethod("onCreate", Bundle::class.java) {
+ after { this@Main.onActivity(thisObject as Activity) }
+ }
+
+ this@Main.onLoad(param)
+
+ hooked = true
+ }
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
+ for (module in modules) module.onLoad(packageParam)
+ }
+
+ override fun onContext(context: Context) {
+ for (module in modules) module.onContext(context)
+ }
+
+ override fun onActivity(activity: Activity) {
+ for (module in modules) module.onActivity(activity)
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Module.kt b/app/src/main/kotlin/io/github/revenge/xposed/Module.kt
new file mode 100644
index 0000000..8813346
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/Module.kt
@@ -0,0 +1,192 @@
+package ShiggyXposed.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
+ get() = param.args
+
+ var result: Any?
+ get() = param.result
+ set(value) {
+ param.result = value
+ }
+
+ var throwable: Throwable?
+ get() = param.throwable
+ set(value) {
+ param.throwable = value
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt b/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt
new file mode 100644
index 0000000..a5c2e68
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt
@@ -0,0 +1,32 @@
+package ShiggyXposed.xposed
+
+import android.app.AndroidAppHelper
+import android.content.Intent
+import kotlin.system.exitProcess
+import kotlinx.serialization.json.Json
+
+class Utils {
+ companion object {
+ val JSON = Json { ignoreUnknownKeys = true }
+
+ fun reloadApp() {
+ val application = AndroidAppHelper.currentApplication()
+ val intent =
+ application.packageManager.getLaunchIntentForPackage(application.packageName)
+ application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
+ exitProcess(0)
+ }
+ }
+
+ object Log {
+ fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg)
+ fun e(msg: String, throwable: Throwable) =
+ android.util.Log.e(Constants.LOG_TAG, msg, throwable)
+ fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg)
+ fun i(msg: String, throwable: Throwable) =
+ android.util.Log.i(Constants.LOG_TAG, msg, throwable)
+ fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg)
+ fun w(msg: String, throwable: Throwable) =
+ android.util.Log.w(Constants.LOG_TAG, msg, throwable)
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt
new file mode 100644
index 0000000..b0bee81
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt
@@ -0,0 +1,21 @@
+package ShiggyXposed.xposed.modules
+
+import ShiggyXposed.xposed.Constants.Companion.TARGET_PACKAGE
+import ShiggyXposed.xposed.Module
+import android.content.res.Resources
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+
+/** Hooks [Resources.getIdentifier] to fix resource package name mismatch. */
+class FixResourcesModule : Module() {
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
+ with(packageParam) {
+ if (packageName != TARGET_PACKAGE) {
+ Resources::class.java.hookMethod(
+ "getIdentifier",
+ String::class.java,
+ String::class.java,
+ String::class.java
+ ) { before { if (args[2] == packageName) args[2] = TARGET_PACKAGE } }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt
new file mode 100644
index 0000000..35346ee
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt
@@ -0,0 +1,132 @@
+package ShiggyXposed.xposed.modules
+
+import ShiggyXposed.xposed.Constants
+import ShiggyXposed.xposed.Module
+import ShiggyXposed.xposed.Utils.Log
+import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
+import android.content.res.AssetManager
+import android.content.res.XModuleResources
+import de.robv.android.xposed.IXposedHookZygoteInit
+import de.robv.android.xposed.XposedBridge
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import 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)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt
new file mode 100644
index 0000000..5ef94c5
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt
@@ -0,0 +1,128 @@
+package ShiggyXposed.xposed.modules
+
+import ShiggyXposed.xposed.Constants
+import ShiggyXposed.xposed.Module
+import ShiggyXposed.xposed.Utils.Companion.reloadApp
+import ShiggyXposed.xposed.Utils.Log
+import android.app.AlertDialog
+import android.content.Context
+import de.robv.android.xposed.XC_MethodHook
+import de.robv.android.xposed.XposedBridge
+import de.robv.android.xposed.XposedHelpers
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import java.io.File
+
+class LogBoxModule : Module() {
+ lateinit var packageParam: XC_LoadPackage.LoadPackageParam
+ lateinit var bridgeDevSupportManagerClass: Class<*>
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
+ with(packageParam) {
+ this@LogBoxModule.packageParam = packageParam
+
+ try {
+ val dcdReactNativeHostClass =
+ classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
+ bridgeDevSupportManagerClass =
+ classLoader.loadClass(
+ "com.facebook.react.devsupport.BridgeDevSupportManager"
+ )
+
+ val getUseDeveloperSupportMethod =
+ dcdReactNativeHostClass.methods.first {
+ it.name == "getUseDeveloperSupport"
+ }
+ val handleReloadJSMethod =
+ bridgeDevSupportManagerClass.methods.first {
+ it.name == "handleReloadJS"
+ }
+ val showDevOptionsDialogMethod =
+ bridgeDevSupportManagerClass.methods.first {
+ it.name == "showDevOptionsDialog"
+ }
+ val mReactInstanceDevHelperField =
+ XposedHelpers.findField(
+ bridgeDevSupportManagerClass,
+ "mReactInstanceDevHelper"
+ )
+
+ // This enables the LogBox and opens dev option on shake
+ XposedBridge.hookMethod(
+ getUseDeveloperSupportMethod,
+ object : XC_MethodHook() {
+ override fun beforeHookedMethod(param: MethodHookParam) {
+ param.result = true
+ }
+ }
+ )
+
+ // Replace the method to direct relaunch the app instead of sending reload
+ // command to developer server
+ XposedBridge.hookMethod(
+ handleReloadJSMethod,
+ object : XC_MethodHook() {
+ override fun beforeHookedMethod(param: MethodHookParam) {
+ reloadApp()
+ param.result = null
+ }
+ }
+ )
+
+ // Triggered on shake - Moved from onContext to onLoad
+ XposedBridge.hookMethod(
+ showDevOptionsDialogMethod,
+ object : XC_MethodHook() {
+ override fun beforeHookedMethod(param: MethodHookParam) {
+ try {
+ val mReactInstanceDevHelper =
+ mReactInstanceDevHelperField.get(param.thisObject)
+ val getCurrentActivityMethod =
+ mReactInstanceDevHelper.javaClass.methods.first {
+ it.name == "getCurrentActivity"
+ }
+ val context =
+ getCurrentActivityMethod.invoke(
+ mReactInstanceDevHelper
+ ) as
+ Context
+
+ showRecoveryAlert(context)
+ } catch (ex: Exception) {
+ Log.e("Failed to show dev options dialog: $ex")
+ }
+
+ // Ignore the original dev menu
+ param.result = null
+ }
+ }
+ )
+ } catch (e: Exception) {
+ Log.e("Error in LogBoxModule.onLoad: $e")
+ }
+
+ return@with
+ }
+
+ private fun showRecoveryAlert(context: Context) {
+ AlertDialog.Builder(context)
+ .setTitle("ShiggyXposed Recovery Options")
+ .setItems(arrayOf("Reload", "Delete bundle.js")) { _, which ->
+ when (which) {
+ 0 -> {
+ reloadApp()
+ }
+ 1 -> {
+ val bundleFile =
+ File(
+ packageParam.appInfo.dataDir,
+ "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
+ )
+ if (bundleFile.exists()) bundleFile.delete()
+
+ reloadApp()
+ }
+ }
+ }
+ .show()
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt
new file mode 100644
index 0000000..17a6de5
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt
@@ -0,0 +1,108 @@
+package ShiggyXposed.xposed.modules
+
+import ShiggyXposed.xposed.BuildConfig
+import ShiggyXposed.xposed.Constants
+import ShiggyXposed.xposed.Module
+import ShiggyXposed.xposed.Utils.Companion.JSON
+import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
+import android.content.res.AssetManager
+import de.robv.android.xposed.XC_MethodHook
+import de.robv.android.xposed.XposedBridge
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import java.io.File
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+
+// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule
+// if needed
+class PayloadGlobalModule(private val modules: List) : Module() {
+ private companion object {
+ const val GLOBAL_NAME = "__PYON_LOADER__"
+ }
+
+ private fun getPayloadString(): String =
+ JSON.encodeToString(
+ buildJsonObject {
+ put("loaderName", Constants.LOADER_NAME)
+ put("loaderVersion", BuildConfig.VERSION_NAME)
+ @Suppress("DEPRECATION") for (module in modules) module.buildPayload(this)
+ }
+ )
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
+ with(packageParam) {
+ val catalystInstance =
+ classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl")
+ val reactInstance =
+ classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1")
+
+ val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit =
+ { param, key, json ->
+ runCatching {
+ // Attempt old CatalystInstanceImpl method
+ catalystInstance!!
+ .method(
+ "setGlobalVariable",
+ String::class.java,
+ String::class.java
+ )
+ .invoke(param.thisObject, key, json)
+ }
+ .onFailure {
+ // Failed (likely because it's a stub), use a workaround
+ val preloadsDir =
+ File(
+ "${appInfo.dataDir}/${Constants.FILES_DIR}",
+ PRELOADS_DIR
+ )
+ File(preloadsDir, "rv_globals_$key.js").apply {
+ writeText("this[${JSON.encodeToString(key)}]=$json")
+
+ XposedBridge.invokeOriginalMethod(
+ reactInstance!!.method(
+ "loadScriptFromFile",
+ String::class.java,
+ String::class.java,
+ Boolean::class.javaPrimitiveType
+ ),
+ param.thisObject,
+ arrayOf(
+ absolutePath,
+ absolutePath,
+ param.args[2]
+ )
+ )
+
+ delete()
+ }
+ }
+ }
+
+ val hook =
+ MethodHookBuilder().run {
+ before { setGlobalVariable(param, GLOBAL_NAME, getPayloadString()) }
+
+ build()
+ }
+
+ listOf(catalystInstance, reactInstance).forEach { if (it != null) hook(it, hook) }
+ }
+
+ private fun hook(instance: Class<*>, hook: XC_MethodHook) {
+ instance.method(
+ "loadScriptFromAssets",
+ AssetManager::class.java,
+ String::class.java,
+ Boolean::class.javaPrimitiveType,
+ )
+ .hook(hook)
+
+ instance.method(
+ "loadScriptFromFile",
+ String::class.java,
+ String::class.java,
+ Boolean::class.javaPrimitiveType
+ )
+ .hook(hook)
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt
new file mode 100644
index 0000000..a640780
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt
@@ -0,0 +1,148 @@
+package ShiggyXposed.xposed.modules
+
+import ShiggyXposed.xposed.Constants
+import ShiggyXposed.xposed.Module
+import ShiggyXposed.xposed.Utils.Companion.JSON
+import ShiggyXposed.xposed.Utils.Log
+import android.app.Activity
+import android.util.AtomicFile
+import androidx.core.util.writeBytes
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.engine.cio.*
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import java.io.File
+import kotlinx.coroutines.*
+import kotlinx.serialization.Serializable
+
+@Serializable data class CustomLoadUrl(val enabled: Boolean = false, val url: String = "")
+
+@Serializable data class LoaderConfig(val customLoadUrl: CustomLoadUrl = CustomLoadUrl())
+
+/**
+ * Module that updates the JS bundle by downloading it from a remote URL.
+ *
+ * Shows dialogs when failed allowing retry.
+ */
+class UpdaterModule : Module() {
+ private lateinit var config: LoaderConfig
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var error: Throwable? = null
+
+ private lateinit var cacheDir: File
+ private lateinit var bundle: File
+ private lateinit var etag: File
+
+ companion object {
+ var job: Job? = null
+
+ private const val TIMEOUT_CACHED = 5000L
+ private const val TIMEOUT = 50000L
+ private const val ETAG_FILE = "etag.txt"
+ private const val CONFIG_FILE = "loader.json"
+
+ private const val DEFAULT_BUNDLE_URL =
+ "https://github.com/kmmiio99o/ShiggyCord/releases/download/v1.0.0/shiggycord.js"
+ }
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
+ with(packageParam) {
+ cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() }
+ val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
+
+ bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
+ etag = File(cacheDir, ETAG_FILE)
+
+ val configFile = File(filesDir, CONFIG_FILE)
+
+ config =
+ runCatching {
+ if (configFile.exists()) {
+ JSON.decodeFromString(configFile.readText())
+ } else LoaderConfig()
+ }
+ .getOrDefault(LoaderConfig())
+
+ downloadScript()
+ }
+
+ private fun downloadScript(activity: Activity? = null) {
+ job =
+ scope.launch {
+ try {
+ HttpClient(CIO) {
+ expectSuccess = false
+ install(UserAgent) { agent = Constants.USER_AGENT }
+ install(HttpRedirect) {}
+ }
+ .use { client ->
+ val url =
+ config.customLoadUrl.takeIf { it.enabled }?.url
+ ?: DEFAULT_BUNDLE_URL
+ Log.i("Fetching JS bundle from: $url")
+
+ val response: HttpResponse =
+ client.get(url) {
+ headers {
+ if (etag.exists() && bundle.exists()) {
+ append(
+ HttpHeaders.IfNoneMatch,
+ etag.readText()
+ )
+ }
+ }
+
+ // Retries don't need timeout
+ if (activity != null) {
+ 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) {}
+ }
+ }
+ HttpStatusCode.NotModified -> {
+ Log.i("Server responded with 304, no changes")
+ }
+ else -> {
+ throw ResponseException(
+ response,
+ "Received status: ${response.status}"
+ )
+ }
+ }
+ }
+ } catch (e: Throwable) {
+ Log.e("Failed to download script", e)
+ error = e
+ }
+ }
+ }
+
+ override fun onActivity(activity: Activity) {
+ error ?: return
+ error = null
+ downloadScript(activity)
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt
new file mode 100644
index 0000000..1b62ebb
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt
@@ -0,0 +1,271 @@
+package ShiggyXposed.xposed.modules.appearance
+
+import ShiggyXposed.xposed.Constants
+import ShiggyXposed.xposed.Module
+import ShiggyXposed.xposed.Utils.Companion.JSON
+import ShiggyXposed.xposed.Utils.Log
+import android.content.res.AssetManager
+import android.graphics.Typeface
+import android.graphics.Typeface.CustomFallbackBuilder
+import android.graphics.fonts.Font
+import android.graphics.fonts.FontFamily
+import android.os.Build
+import de.robv.android.xposed.XC_MethodReplacement
+import de.robv.android.xposed.XposedHelpers
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import io.ktor.client.*
+import io.ktor.client.call.*
+import io.ktor.client.engine.cio.*
+import io.ktor.client.plugins.*
+import io.ktor.client.request.*
+import io.ktor.client.statement.*
+import io.ktor.http.*
+import java.io.File
+import java.io.IOException
+import kotlinx.coroutines.*
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonObjectBuilder
+import kotlinx.serialization.json.put
+
+@Serializable
+data class FontDefinition(
+ val name: String? = null,
+ val description: String? = null,
+ val spec: Int? = null,
+ val main: Map,
+)
+
+class FontsModule : Module() {
+ private companion object {
+ val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic")
+ val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
+ const val FONTS_ASSET_PATH = "fonts/"
+ }
+
+ private lateinit var fontsDir: File
+ private lateinit var fontsDownloadsDir: File
+ private var fontsAbsPath: String? = null
+
+ override fun buildPayload(builder: JsonObjectBuilder) {
+ builder.apply { put("fontPatch", 2) }
+ }
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
+ with(packageParam) {
+ XposedHelpers.findAndHookMethod(
+ "com.facebook.react.views.text.ReactFontManager\$Companion",
+ classLoader,
+ "createAssetTypeface",
+ String::class.java,
+ Int::class.java,
+ "android.content.res.AssetManager",
+ object : XC_MethodReplacement() {
+ override fun replaceHookedMethod(param: MethodHookParam): Typeface? {
+ val fontFamilyName: String = param.args[0].toString()
+ val style: Int = param.args[1] as Int
+ val assetManager: AssetManager = param.args[2] as AssetManager
+ return createAssetTypeface(fontFamilyName, style, assetManager)
+ }
+ }
+ )
+
+ val fontDefFile =
+ File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply {
+ asFile()
+ }
+ if (!fontDefFile.exists()) return@with
+
+ val fontDef =
+ try {
+ JSON.decodeFromString(fontDefFile.readText())
+ } catch (_: Throwable) {
+ return@with
+ }
+
+ fontsDownloadsDir =
+ File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply {
+ asDir()
+ }
+ fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() }
+ fontsAbsPath = fontsDir.absolutePath + "/"
+
+ fontsDir.listFiles()?.forEach { file ->
+ val fileName = file.name
+ if (!fileName.startsWith(".")) {
+ val fontName = fileName.split('.')[0]
+ if (fontDef.main.keys.none { it == fontName }) {
+ Log.i("Deleting font file: $fileName")
+ file.delete()
+ }
+ }
+ }
+
+ // These files should be downloaded by the JS side, but oh well
+ CoroutineScope(Dispatchers.IO).launch {
+ fontDef.main
+ .keys
+ .map { name ->
+ async {
+ val url = fontDef.main.getValue(name)
+ try {
+ Log.i("Downloading $name from $url")
+ val file =
+ File(
+ fontsDir,
+ "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}"
+ )
+ .apply { asFile() }
+ if (file.exists()) return@async
+
+ val client =
+ HttpClient(CIO) {
+ install(UserAgent) {
+ agent = Constants.USER_AGENT
+ }
+ }
+
+ val response: HttpResponse = client.get(url)
+ if (response.status == HttpStatusCode.OK)
+ file.writeBytes(response.body())
+
+ return@async
+ } catch (e: Throwable) {
+ Log.e("Failed to download fonts ($name from $url)", e)
+ }
+ }
+ }
+ .awaitAll()
+ }
+ }
+
+ private fun createAssetTypefaceWithFallbacks(
+ fontFamilyNames: Array,
+ style: Int,
+ assetManager: AssetManager
+ ): Typeface? {
+ val fontFamilies: MutableList = ArrayList()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ // Iterate over the list of fontFamilyNames, constructing new FontFamily objects
+ // for use in the CustomFallbackBuilder below.
+ for (fontFamilyName in fontFamilyNames) {
+ try {
+ for (fileExtension in FILE_EXTENSIONS) {
+ val (customName, refName) = fontFamilyName.split(":")
+ val file =
+ File(fontsDownloadsDir, "$customName/$refName.$fileExtension")
+ .apply { asFile() }
+ val font = Font.Builder(file).build()
+ val family = FontFamily.Builder(font).build()
+ fontFamilies.add(family)
+ }
+ } catch (_: Throwable) {}
+
+ for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
+ for (fileExtension in FILE_EXTENSIONS) {
+ val fileName =
+ StringBuilder()
+ .append(fontRootPath)
+ .append(fontFamilyName)
+ .append(fileExtension)
+ .toString()
+ try {
+ val builder =
+ if (fileName[0] == '/') Font.Builder(File(fileName))
+ else Font.Builder(assetManager, fileName)
+ val font = builder.build()
+ val family = FontFamily.Builder(font).build()
+ fontFamilies.add(family)
+ } catch (_: RuntimeException) {
+ // If the typeface asset does not exist, try another extension.
+ continue
+ } catch (_: IOException) {
+ // If the font asset does not exist, try another extension.
+ continue
+ }
+ }
+ }
+ }
+
+ // If there's some problem constructing fonts, fall back to the default behavior.
+ if (fontFamilies.isEmpty())
+ return createAssetTypeface(fontFamilyNames[0], style, assetManager)
+
+ val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0])
+ for (i in 1 until fontFamilies.size) {
+ fallbackBuilder.addCustomFallback(fontFamilies[i])
+ }
+ return fallbackBuilder.build()
+ }
+ return null
+ }
+
+ private fun createAssetTypeface(
+ fontFamilyName: String,
+ style: Int,
+ assetManager: AssetManager
+ ): Typeface? {
+ // This logic attempts to safely check if the frontend code is attempting to use
+ // fallback fonts, and if it is, to use the fallback typeface creation logic.
+ var fontFamilyName: String = fontFamilyName
+ val fontFamilyNames =
+ fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ for (i in fontFamilyNames.indices) {
+ fontFamilyNames[i] = fontFamilyNames[i].trim()
+ }
+
+ // If there are multiple font family names:
+ // For newer versions of Android, construct a Typeface with fallbacks
+ // For older versions of Android, ignore all the fallbacks and just use the first font
+ // family
+ if (fontFamilyNames.size > 1) {
+ fontFamilyName =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return createAssetTypefaceWithFallbacks(
+ fontFamilyNames,
+ style,
+ assetManager
+ )
+ } else {
+ fontFamilyNames[0]
+ }
+ }
+
+ val extension = EXTENSIONS[style]
+
+ try {
+ for (fileExtension in FILE_EXTENSIONS) {
+ val (customName, refName) = fontFamilyName.split(":")
+ val file =
+ File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply {
+ asFile()
+ }
+ if (!file.exists()) throw Exception()
+ return Typeface.createFromFile(file.absolutePath)
+ }
+ } catch (_: Throwable) {}
+
+ // Lastly, after all those checks above, this is the original RN logic for
+ // getting the typeface.
+ for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
+ for (fileExtension in FILE_EXTENSIONS) {
+ val fileName =
+ StringBuilder()
+ .append(fontRootPath)
+ .append(fontFamilyName)
+ .append(extension)
+ .append(fileExtension)
+ .toString()
+
+ return try {
+ if (fileName[0] == '/') Typeface.createFromFile(fileName)
+ else Typeface.createFromAsset(assetManager, fileName)
+ } catch (_: RuntimeException) {
+ // If the typeface asset does not exist, try another extension.
+ continue
+ }
+ }
+ }
+
+ return Typeface.create(fontFamilyName, style)
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt
new file mode 100644
index 0000000..121c86a
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt
@@ -0,0 +1,45 @@
+package ShiggyXposed.xposed.modules.appearance
+
+import ShiggyXposed.xposed.Module
+import android.R.color
+import android.app.AndroidAppHelper
+import android.content.Context
+import android.os.Build
+import androidx.core.content.ContextCompat
+import 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)
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt
new file mode 100644
index 0000000..87f372a
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt
@@ -0,0 +1,160 @@
+package ShiggyXposed.xposed.modules.appearance
+
+import ShiggyXposed.xposed.Constants
+import ShiggyXposed.xposed.Module
+import ShiggyXposed.xposed.Utils.Companion.JSON
+import android.content.Context
+import android.content.res.Resources
+import androidx.core.graphics.toColorInt
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import java.io.File
+import 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? = null,
+ val spec: Int,
+ val semanticColors: Map>? = null,
+ val rawColors: Map? = null
+)
+
+@Serializable data class Theme(val id: String, val selected: Boolean, val data: ThemeData)
+
+class ThemesModule : Module() {
+ private lateinit var param: XC_LoadPackage.LoadPackageParam
+
+ private var theme: Theme? = null
+ private val rawColorMap = mutableMapOf()
+
+ 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!!))
+ 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(themeText)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ fun hookTheme() {
+ val themeManager = param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt")
+ val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme")
+ val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme")
+
+ val theme = this.theme ?: return
+
+ // Apply rawColors
+ theme.data.rawColors?.forEach { (key, value) ->
+ rawColorMap[key.lowercase()] = hexStringToColorInt(value)
+ }
+
+ // Apply semanticColors
+ theme.data.semanticColors?.forEach { (key, value) ->
+ // TEXT_NORMAL -> getTextNormal
+ val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
+ value.forEachIndexed { index, v ->
+ when (index) {
+ 0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v))
+ 1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v))
+ }
+ }
+ }
+
+ // If there's any rawColors value, hook the color getter
+ if (!theme.data.rawColors.isNullOrEmpty()) {
+ val getColorCompat =
+ themeManager.getDeclaredMethod(
+ "getColorCompat",
+ Resources::class.java,
+ Int::class.javaPrimitiveType,
+ Resources.Theme::class.java,
+ )
+
+ val getColorCompatLegacy =
+ themeManager.getDeclaredMethod(
+ "getColorCompat",
+ Context::class.java,
+ Int::class.javaPrimitiveType
+ )
+
+ val patch =
+ MethodHookBuilder().run {
+ before {
+ val arg1 = args[0]
+ val resources =
+ if (arg1 is Context) arg1.resources else (arg1 as Resources)
+ val name = resources.getResourceEntryName(args[1] as Int)
+
+ if (rawColorMap[name] != null) result = rawColorMap[name]
+ }
+
+ build()
+ }
+
+ getColorCompat.hook(patch)
+ getColorCompatLegacy.hook(patch)
+ }
+ }
+
+ // Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB"
+ private fun hexStringToColorInt(hexString: String): Int {
+ return if (hexString.length == 9) {
+ // Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy
+ val alpha = hexString.substring(7, 9)
+ val rrggbb = hexString.substring(1, 7)
+ "#$alpha$rrggbb".toColorInt()
+ } else hexString.toColorInt()
+ }
+
+ private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
+ try {
+ themeClass.getDeclaredMethod(methodName).let { method ->
+ method.hook { before { result = themeValue } }
+ }
+ } catch (_: NoSuchMethodException) {}
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt
new file mode 100644
index 0000000..74bcc4b
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt
@@ -0,0 +1,52 @@
+package ShiggyXposed.xposed.modules.bridge
+
+import ShiggyXposed.xposed.Module
+import android.app.AlertDialog
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Build
+import android.widget.Toast
+
+class AdditionalBridgeMethodsModule : Module() {
+ override fun onContext(context: Context) =
+ with(context) {
+ BridgeModule.registerMethod("revenge.alertError") {
+ val (error, version) = it
+ val app = getAppInfo()
+ val errorString = "$error"
+
+ val clipboard =
+ applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as
+ ClipboardManager
+ val clip = ClipData.newPlainText("Stack Trace", errorString)
+
+ AlertDialog.Builder(this)
+ .setTitle("ShiggyXposed Error")
+ .setMessage(
+ """
+ ShiggyXposed: $version
+ ${app.name}: ${app.version} (${app.versionCode})
+ Device: ${Build.MANUFACTURER} ${Build.MODEL}
+
+
+ """.trimIndent() +
+ errorString
+ )
+ .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
+ .setNeutralButton("Copy") { dialog, _ ->
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(
+ applicationContext,
+ "Copied stack trace",
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ dialog.dismiss()
+ }
+ .show()
+
+ null
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt
new file mode 100644
index 0000000..eac6d27
--- /dev/null
+++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt
@@ -0,0 +1,202 @@
+package ShiggyXposed.xposed.modules.bridge
+
+import ShiggyXposed.xposed.BuildConfig
+import ShiggyXposed.xposed.Constants
+import ShiggyXposed.xposed.Module
+import ShiggyXposed.xposed.Utils.Log
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import java.io.File
+import java.lang.reflect.Method
+
+/**
+ * See for possible return types:
+ * https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19
+ *
+ * You may return a [Unit] and the resulting value will be `null`.
+ */
+typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any?
+
+typealias BridgeMethodArgs = ArrayList
+
+/**
+ * A module that exposes a bridge for calling methods from JavaScript.
+ *
+ * To call a method, pass an object with the following structure to a hooked method:
+ * ```js
+ * {
+ * revenge: {
+ * method: "method.name",
+ * args: [arg1, arg2, ...]
+ * }
+ * }
+ * ```
+ *
+ * For methods that accept additional arguments, only the first object argument is used for the
+ * bridge call.
+ *
+ * The result will be an object with either a `result` or `error` key:
+ * ```js
+ * {
+ * result: ... // The return value of the method
+ * }
+ * ```
+ * or
+ * ```js
+ * {
+ * error: "Error message"
+ * }
+ * ```
+ */
+class BridgeModule : Module() {
+ private lateinit var readableMapGetString: Method
+ private lateinit var readableMapToHashMap: Method
+ private lateinit var argumentsMakeNative: Method
+
+ companion object {
+ private const val CALL_DATA_KEY = "revenge"
+ private const val METHOD_NAME_KEY = "method"
+ private const val METHOD_ARGS_KEY = "args"
+
+ private fun File.openFileGuarded() {
+ if (!this.exists()) throw Error("Path does not exist: $path")
+ if (!this.isFile) throw Error("Path is not a file: $path")
+ }
+
+ private val methods: MutableMap =
+ mutableMapOf(
+ "revenge.info" to
+ {
+ mapOf(
+ "name" to Constants.LOADER_NAME,
+ "version" to BuildConfig.VERSION_CODE
+ )
+ },
+ "revenge.test" to
+ {
+ mapOf(
+ "string" to "string",
+ "number" to 7256,
+ "array" to listOf("testing", 527737, listOf(true)),
+ "object" to mapOf("nested" to true),
+ "boolean" to false,
+ "args" to it,
+ )
+ },
+ "revenge.fs.delete" to
+ {
+ val (path) = it
+ File(path as String).run {
+ if (this.isDirectory) this.deleteRecursively()
+ else this.delete()
+ }
+ },
+ "revenge.fs.exists" to
+ {
+ val (path) = it
+ File(path as String).exists()
+ },
+ "revenge.fs.read" to
+ { it ->
+ val (path) = it
+ val file = File(path as String).apply { openFileGuarded() }
+
+ file.bufferedReader().use { it.readText() }
+ },
+ "revenge.fs.write" to
+ { it ->
+ val (path, contents) = it
+ val file = File(path as String).apply { openFileGuarded() }
+
+ file.writeText(contents as String)
+ }
+ )
+
+ /**
+ * Registers a bridge method that can be called from JavaScript.
+ *
+ * If the method is already registered, it will be overwritten.
+ *
+ * @param name The name of the method to register.
+ * @param callback The callback to invoke when the method is called.
+ */
+ fun registerMethod(name: String, callback: BridgeMethodCallback) {
+ if (methods.containsKey(name))
+ Log.w("Bridge method already exists and will be overridden: $name")
+ methods[name] = callback
+ }
+ }
+
+ override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
+ with(packageParam) {
+ val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments")
+ val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap")
+ val promise = classLoader.loadClass("com.facebook.react.bridge.Promise")
+
+ val promiseResolve = promise.method("resolve", Object::class.java)
+ argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java)
+ readableMapGetString = readableMap.method("getString", String::class.java)
+ readableMapToHashMap = readableMap.method("toHashMap")
+
+ classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager").hookMethod(
+ "getBBox",
+ Double::class.javaObjectType,
+ readableMap
+ ) {
+ before {
+ callBridgeMethod(readableMapToHashMap(args[1]!!))?.let {
+ result = it.toNativeObject()
+ }
+ }
+ }
+
+ classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule")
+ .hookMethod("readAsDataURL", readableMap, promise) {
+ before {
+ val (readableMap, promise) = args
+ callBridgeMethod(readableMapToHashMap(readableMap!!))?.let {
+ promiseResolve.invoke(promise, it.toNativeObject())
+ result = null
+ }
+ }
+ }
+
+ return@with
+ }
+
+ private fun Any?.toNativeObject(): Any? =
+ argumentsMakeNative.invoke(
+ null,
+ when (this) {
+ Unit -> null
+ else -> this
+ }
+ )
+
+ private fun readableMapToHashMap(map: Any): HashMap {
+ @Suppress("UNCHECKED_CAST") return readableMapToHashMap.invoke(map) as HashMap
+ }
+
+ private fun callBridgeMethod(hashMap: HashMap): Map? =
+ try {
+ val (method, args) = getBridgeCallData(hashMap) ?: return null
+ val ret = method(args).toNativeObject()
+ mapOf("result" to ret)
+ } catch (e: Throwable) {
+ mapOf("error" to e.toString())
+ }
+
+ private fun getBridgeCallData(
+ hashMap: HashMap
+ ): Pair? {
+ @Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap?
+ data ?: return null
+
+ @Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String
+ val method = methods[name]
+ method ?: throw Error("Method not registered: $name")
+
+ @Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs
+
+ return Pair(method, args)
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6a025b9..a9a427b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,3 @@
- KettuXposed
-
\ No newline at end of file
+ ShiggyXposed
+
diff --git a/build.gradle.kts b/build.gradle.kts
index 56b0a88..d421a94 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,7 +5,7 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath("com.android.tools.build:gradle:8.9.2")
+ classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
// NOTE: Do not place your application dependencies here; they belong
@@ -23,4 +23,4 @@ allprojects {
tasks.register("clean") {
delete(rootProject.layout.buildDirectory)
-}
\ No newline at end of file
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
deleted file mode 100644
index cc01b00..0000000
--- a/gradle/libs.versions.toml
+++ /dev/null
@@ -1,43 +0,0 @@
-[versions]
-agp = "8.9.2"
-kotlin = "2.1.0"
-coreKtx = "1.15.0"
-junit = "4.13.2"
-junitVersion = "1.2.1"
-espressoCore = "3.6.1"
-appcompat = "1.7.0"
-material = "1.12.0"
-xposed = "82"
-yukiApi = "1.2.1"
-ksp = "2.1.0-1.0.29"
-aliuhook = "main-SNAPSHOT"
-libunbound = "1.0.0"
-kotlinxSerializationJson = "1.8.0"
-kotlinxCoroutinesAndroid = "1.10.1"
-ktorClientCore = "3.1.1"
-ktorClientCio = "3.1.1"
-kotlinReflect = "2.1.20-RC"
-kotlinSerialization = "2.1.0"
-
-[libraries]
-androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
-junit = { group = "junit", name = "junit", version.ref = "junit" }
-androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
-androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
-androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
-aliuhook = { group = "com.aliucord", name = "Aliuhook", version.ref = "aliuhook" }
-xposed = { group = "de.robv.android.xposed", name = "api", version.ref = "xposed" }
-yuki-api = { group = "com.highcapable.yukihookapi", name = "api", version.ref = "yukiApi" }
-libunbound = { group = "dev.rushii", name = "libunbound", version.ref = "libunbound" }
-kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
-kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
-ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktorClientCore" }
-ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktorClientCio" }
-kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlinReflect" }
-
-[plugins]
-android-application = { id = "com.android.application", version.ref = "agp" }
-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
-kotlin-serialization = { id= "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" }
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index e2847c8..37f853b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME