replace files to the ones from codeberg

This commit is contained in:
kmmiio99o 2025-10-11 20:07:05 +02:00
parent d39f81f34e
commit 2ac8ed15f2
45 changed files with 1684 additions and 1195 deletions

View file

@ -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
- name: "Upload release APK"
uses: actions/upload-artifact@v4
with:
name: app-release
path: /tmp/ShiggyXposedbuild/apks/app-release.apk

5
.gitignore vendored
View file

@ -13,6 +13,5 @@ build
.externalNativeBuild
.cxx
local.properties
.idea/AndroidProjectSystem.xml
/app/release
/app/build
app/release/release/
release

2
.idea/.name generated
View file

@ -1 +1 @@
KettuXposed
ShiggyXposed

6
.idea/AndroidProjectSystem.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

View file

@ -1,121 +1,10 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="AUTODETECT_INDENTS" value="false" />
<option name="SOFT_MARGINS" value="120" />
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State />
</entry>
</value>
</component>
</project>

View file

@ -4,14 +4,6 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-05-05T09:01:33.514844700Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Cocobo\.android\avd\Medium_Phone_API_VanillaIceCream.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>

13
.idea/deviceManager.xml generated Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

1
.idea/gradle.xml generated
View file

@ -13,7 +13,6 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>

View file

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="BintrayJCenter" />
<option name="name" value="BintrayJCenter" />
<option name="url" value="https://jcenter.bintray.com/" />
</remote-repository>
<remote-repository>
<option name="id" value="Google" />
<option name="name" value="Google" />
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
</remote-repository>
<remote-repository>
<option name="id" value="MavenRepo" />
<option name="name" value="MavenRepo" />
<option name="url" value="https://repo.maven.apache.org/maven2/" />
</remote-repository>
</component>
</project>

6
.idea/kotlinc.xml generated
View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.1.10" />
</component>
</project>

8
.idea/misc.xml generated
View file

@ -1,9 +1,7 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK" />
</project>

2
.idea/vcs.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View file

@ -1,3 +0,0 @@
{
"java.configuration.updateBuildConfiguration": "disabled"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

View file

@ -1,28 +0,0 @@
<div align="center">
<img src="Images/kettu.png" alt="Kettu logo" width="200px" style="border-radius: 50%" />
# KettuXposed
Easily install Kettu on your rooted device!
[![Latest release](https://img.shields.io/github/v/release/C0C0B01/KettuXposed?color=3AB8BA&display_name=release&label=Latest&style=for-the-badge)](https://github.com/C0C0B01/KettuXposed/releases/latest)
---
<br>
![Downloads (latest)](https://img.shields.io/github/downloads/C0C0B01/KettuXposed/latest/total?style=for-the-badge&logo=github&label=Downloads%20(Latest)&color=blue)
![Total downloads](https://img.shields.io/github/downloads/C0C0B01/KettuXposed/total?style=for-the-badge&logo=github&label=Downloads%20(Total)&color=blue)
<br>
</div>
## 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)

View file

@ -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 {
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")
}

View file

@ -1,25 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
tools:targetApi="31"
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
tools:targetApi="31"
>
<meta-data
android:name="xposedmodule"
android:value="true" />
android:name="xposedmodule"
android:value="true"/>
<meta-data
android:name="xposeddescription"
android:value="An Xposed module to inject Kettu, a mod for Discord's mobile apps." />
android:name="xposeddescription"
android:value="Xposed module to inject ShiggyCord into discord"/>
<meta-data
android:name="xposedminversion"
android:value="82" />
android:name="xposedminversion"
android:value="82"/>
<meta-data
android:name="xposedscope"
android:value="com.discord"
android:name="xposedscope"
android:value="com.discord"
/>
</application>
</manifest>

View file

@ -1 +1 @@
cocobo1.pupu.xposed.Main
ShiggyXposed.xposed.Main

View file

@ -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<String, String>,
)
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<FontDefinition>(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<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")
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)
}
}

View file

@ -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)
}
}

View file

@ -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<Module> = 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"
}
})
}
}
}

View file

@ -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) {}
}

View file

@ -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<String>,
val neutral2: List<String>,
val accent1: List<String>,
val accent2: List<String>,
val accent3: List<String>
)
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)
}
}

View file

@ -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<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 ThemeModule : Module() {
private lateinit var param: XC_LoadPackage.LoadPackageParam
private var theme: Theme? = null
private val rawColorMap = mutableMapOf<String, Int>()
@ExperimentalSerializationApi
override fun buildJson(builder: JsonObjectBuilder) {
builder.apply {
put("hasThemeSupport", true)
if (theme != null)
put("storedTheme", Json.encodeToJsonElement<Theme>(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<Theme>(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
}
}
}

View file

@ -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"
}
}

View file

@ -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)
}
}

View file

@ -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<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 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)
}
}

View file

@ -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 } }
}
}
}

View file

@ -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)
}
}
}

View file

@ -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()
}
}

View file

@ -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>) : 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 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<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) {
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)
}
}

View file

@ -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<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 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)
}
}

View file

@ -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<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 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
}
}
}

View file

@ -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<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 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<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)
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<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.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

@ -1,3 +1,3 @@
<resources>
<string name="app_name">KettuXposed</string>
<string name="app_name">ShiggyXposed</string>
</resources>

View file

@ -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

View file

@ -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" }

View file

@ -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