Pull KettuXposed and rename it fully

This commit is contained in:
kmmiio99o 2025-11-07 22:19:26 +01:00
parent 7a42f2eaa5
commit 5f26b75039
30 changed files with 1895 additions and 1644 deletions

View file

@ -10,10 +10,10 @@ android {
defaultConfig {
applicationId = "ShiggyXposed.xposed"
minSdk = 22
minSdk = 24
targetSdk = 36
versionCode = 1051
versionName = "1.0.51"
versionCode = 1100
versionName = "1.1.0"
}
buildTypes {
@ -28,31 +28,23 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions { jvmTarget = "17" }
buildFeatures { buildConfig = true }
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
repositories {
mavenCentral()
google()
maven("https://jitpack.io")
}
dependencies {
compileOnly("de.robv.android.xposed:api:82")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
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")
implementation("androidx.core:core-ktx:1.17.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("io.ktor:ktor-client-core:3.3.1")
implementation("io.ktor:ktor-client-cio:3.3.1")
}

View file

@ -1,18 +1,9 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest
package="ShiggyXposed.xposed"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
>
<permission
android:name="dev.shiggy.cord.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"
/>
<uses-permission
android:name="dev.shiggy.cord.DYNAMIC_RECEIVE_NOT_EXPORTED_PERMISSION"
/>
<uses-permission android:name="dev.shiggy.cord.DYNAMIC_RECEIVE" />
<application
android:allowBackup="true"
android:label="@string/app_name"

View file

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

View file

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

View file

@ -7,9 +7,9 @@ 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 kotlinx.serialization.json.JsonObjectBuilder
import java.io.File
import java.lang.reflect.Method
import kotlinx.serialization.json.JsonObjectBuilder
data class AppInfo(
val name: String,
@ -19,13 +19,16 @@ data class AppInfo(
)
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) {}
/**
* 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. */
/**
* Called during Zygote initialization.
*/
open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {}
/**
@ -38,11 +41,10 @@ abstract class Module {
open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {}
/**
* Called after [Context] has been attached to a [android.content.ContextWrapper] via
* [android.content.ContextWrapper.attachBaseContext].
* 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.
* 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.
*/
@ -55,18 +57,15 @@ abstract class Module {
*
* 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.
* 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.
* 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
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode
else @Suppress("DEPRECATION") pInfo.versionCode.toLong()
return AppInfo(
@ -86,22 +85,21 @@ abstract class Module {
if (!this.isFile) this.deleteRecursively()
}
protected fun Class<*>.method(name: String, vararg params: Class<*>?): Method =
getDeclaredMethod(name, *params).apply { isAccessible = true }
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 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(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
name: String, vararg params: Class<*>?, block: MethodHookBuilder.() -> Unit
): XC_MethodHook.Unhook = method(name, *params).hook(MethodHookBuilder().apply(block).build())
protected class MethodHookBuilder {
@ -116,16 +114,14 @@ abstract class Module {
afterBlock = block
}
fun build(): XC_MethodHook =
object : XC_MethodHook() {
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) }
)
val scope = HookScope(
param = param, proceed = { p ->
super.beforeHookedMethod(p)
})
scope.b()
} else {
super.beforeHookedMethod(param)
@ -135,11 +131,10 @@ abstract class Module {
override fun afterHookedMethod(param: MethodHookParam) {
val a = afterBlock
if (a != null) {
val scope =
HookScope(
param = param,
proceed = { p -> super.afterHookedMethod(p) }
)
val scope = HookScope(
param = param, proceed = { p ->
super.afterHookedMethod(p)
})
scope.a()
} else {
super.afterHookedMethod(param)
@ -159,23 +154,19 @@ abstract class Module {
* @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
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.
* 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 thisObject: Any? get() = param.thisObject
val args: Array<Any?>
get() = param.args
val args: Array<Any?> get() = param.args
var result: Any?
get() = param.result

View file

@ -0,0 +1,58 @@
package ShiggyXposed.xposed
import android.app.AlertDialog
import android.app.AndroidAppHelper
import android.content.Context
import android.content.Intent
import ShiggyXposed.xposed.modules.UpdaterModule
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.system.exitProcess
class Utils {
companion object {
val JSON = Json { ignoreUnknownKeys = true }
fun reloadApp() {
val application = AndroidAppHelper.currentApplication()
val intent = application.packageManager.getLaunchIntentForPackage(application.packageName)
application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
exitProcess(0)
}
fun showRecoveryAlert(context: Context) {
AlertDialog.Builder(context).setTitle("ShiggyCord Recovery Options")
.setItems(arrayOf("Reload", "Delete Script", "Reset Loader Config")) { _, which ->
when (which) {
0 -> {
reloadApp()
}
1 -> {
val bundleFile = File(
context.dataDir, "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
)
if (bundleFile.exists()) bundleFile.delete()
reloadApp()
}
2 -> {
UpdaterModule.resetLoaderConfig(context)
reloadApp()
}
}
}.show()
}
}
object Log {
fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg)
fun e(msg: String, throwable: Throwable) = android.util.Log.e(Constants.LOG_TAG, msg, throwable)
fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg)
fun i(msg: String, throwable: Throwable) = android.util.Log.i(Constants.LOG_TAG, msg, throwable)
fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg)
fun w(msg: String, throwable: Throwable) = android.util.Log.w(Constants.LOG_TAG, msg, throwable)
}
}

View file

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

View file

@ -0,0 +1,23 @@
package ShiggyXposed.xposed.modules
import android.content.res.Resources
import de.robv.android.xposed.callbacks.XC_LoadPackage
import ShiggyXposed.xposed.Constants.Companion.TARGET_PACKAGE
import ShiggyXposed.xposed.Module
/**
* 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

@ -1,27 +1,28 @@
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 ShiggyXposed.xposed.Constants
import ShiggyXposed.xposed.HookStateHolder
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils.Log
import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
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.
* 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.
* The main bundle should be placed in the [Constants.CACHE_DIR] directory named [Constants.MAIN_SCRIPT_FILE].
* If the bundle file does not exist, it will attempt to load `assets://Shiggy.bundle` from the module's assets.
*/
class HookScriptLoaderModule : Module() {
private lateinit var preloadsDir: File
@ -38,8 +39,7 @@ class HookScriptLoaderModule : Module() {
this@HookScriptLoaderModule.modulePath = startupParam.modulePath
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) =
with(packageParam) {
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() }
@ -50,22 +50,19 @@ class HookScriptLoaderModule : Module() {
"com.facebook.react.runtime.ReactInstance$1",
// TODO: Remove once Discord fully switches to Bridgeless
"com.facebook.react.bridge.CatalystInstanceImpl"
)
.mapNotNull { classLoader.safeLoadClass(it) }
).mapNotNull { classLoader.safeLoadClass(it) }
.forEach { hook(it) }
}
private fun hook(instance: Class<*>) {
val loadScriptFromAssets =
instance.method(
val loadScriptFromAssets = instance.method(
"loadScriptFromAssets",
AssetManager::class.java,
String::class.java,
Boolean::class.javaPrimitiveType
)
val loadScriptFromFile =
instance.method(
val loadScriptFromFile = instance.method(
"loadScriptFromFile",
String::class.java,
String::class.java,
@ -87,14 +84,15 @@ class HookScriptLoaderModule : Module() {
}
}
private fun HookScope.runCustomScripts(
loadScriptFromFile: Method,
loadScriptFromAssets: Method
) {
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() }
runBlocking {
val ready = async { HookStateHolder.readyDeferred.join() }
val download = async { UpdaterModule.downloadScript().join() }
awaitAll(ready, download)
}
val loadSynchronously = args[2]
val runScriptFile = { file: File ->
@ -110,20 +108,21 @@ class HookScriptLoaderModule : Module() {
}
try {
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
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)
if (!::resources.isInitialized) resources =
XModuleResources.createInstance(modulePath, null)
XposedBridge.invokeOriginalMethod(
loadScriptFromAssets,
thisObject,
arrayOf(resources.assets, "assets://bundle.js", loadSynchronously)
arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously)
)
}
} catch (e: Throwable) {

View file

@ -0,0 +1,355 @@
package ShiggyXposed.xposed.modules
import android.app.AlertDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.EditText
import android.widget.Toast
import de.robv.android.xposed.callbacks.XC_LoadPackage
import ShiggyXposed.xposed.BuildConfig
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils
import ShiggyXposed.xposed.Utils.Log
import de.robv.android.xposed.XposedHelpers
import kotlinx.coroutines.*
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import ShiggyXposed.xposed.Utils.Companion.reloadApp
class LogBoxModule : Module() {
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
this@LogBoxModule.packageParam = packageParam
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
getUseDeveloperSupportMethod.hook {
before {
result = true
}
}
listOf(
"com.facebook.react.devsupport.BridgeDevSupportManager",
"com.facebook.react.devsupport.BridgelessDevSupportManager"
).mapNotNull { packageParam.classLoader.safeLoadClass(it) }.forEach {
hookDevSupportManager(it)
}
return@with
}
private fun hookDevSupportManager(clazz: Class<*>) {
val showDevOptionsDialogMethod = clazz.methods.first { it.name == "showDevOptionsDialog" }
showDevOptionsDialogMethod.hook {
before {
try {
val context = getContextFromDevSupport(clazz, thisObject)
if (context != null) {
showRecoveryMenu(context)
}
result = null
} catch (e: Exception) {
Log.e("Failed to show recovery menu: ${e.message}")
}
}
}
}
private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? {
return try {
val mReactInstanceDevHelperField = XposedHelpers.findField(clazz, "mReactInstanceDevHelper")
val helper = mReactInstanceDevHelperField.get(instance)
val getCurrentActivityMethod = helper?.javaClass?.methods?.first { it.name == "getCurrentActivity" }
getCurrentActivityMethod?.invoke(helper) as? Context
} catch (e: Exception) {
Log.e("Failed to get context: ${e.message}")
null
}
}
private fun reloadReactNative(context: Context) {
try {
val reactInstanceManagerClass =
packageParam.classLoader.loadClass("com.facebook.react.ReactInstanceManager")
val reactApplicationClass = packageParam.classLoader.loadClass("com.facebook.react.ReactApplication")
val app = context.applicationContext
if (reactApplicationClass.isInstance(app)) {
val getReactNativeHostMethod = reactApplicationClass.methods.first { it.name == "getReactNativeHost" }
val reactNativeHost = getReactNativeHostMethod.invoke(app)
val getReactInstanceManagerMethod =
reactNativeHost.javaClass.methods.first { it.name == "getReactInstanceManager" }
val reactInstanceManager = getReactInstanceManagerMethod.invoke(reactNativeHost)
if (reactInstanceManager != null) {
val reloadMethod =
reactInstanceManagerClass.methods.first { it.name == "recreateReactContextInBackground" }
reloadMethod.invoke(reactInstanceManager)
} else {
showError(context, "Error", "Could not get React Instance Manager")
}
} else {
showError(context, "Error", "Application is not a React Application")
}
} catch (e: Exception) {
Log.e("Failed to Reload App: ${e.message}")
showError(context, "Failed to reload", "Error: ${e.message}")
}
}
private fun showRecoveryMenu(context: Context) {
val options = arrayOf(
if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode",
"Refetch Bundle",
"Load Custom Bundle",
"Reload App"
)
AlertDialog.Builder(context)
.setTitle("ShiggyXposed Recovery Menu")
.setItems(options) { _, which ->
handleMenuSelection(context, which)
}
.setNegativeButton("Cancel", null)
.show()
}
private fun handleMenuSelection(context: Context, index: Int) {
when (index) {
0 -> toggleSafeMode(context)
1 -> confirmAction(context, "refetch the bundle") { refetchBundle(context) }
2 -> loadCustomBundle(context)
3 -> reloadApp()
}
}
private fun confirmAction(context: Context, actionText: String, action: () -> Unit) {
AlertDialog.Builder(context)
.setTitle("Confirm Action")
.setMessage("Are you sure you want to $actionText?")
.setPositiveButton("Confirm") { _, _ -> action() }
.setNegativeButton("Cancel", null)
.show()
}
private fun isSafeModeEnabled(context: Context): Boolean {
return try {
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
if (!settingsFile.exists()) return false
val json = JSONObject(settingsFile.readText())
json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false
} catch (e: Exception) {
Log.e("Error checking safe mode: ${e.message}")
false
}
}
private fun toggleSafeMode(context: Context) {
try {
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
val themeFile = File(context.filesDir, "vd_mmkv/VENDETTA_THEMES")
settingsFile.parentFile?.mkdirs()
val settings = if (settingsFile.exists()) {
JSONObject(settingsFile.readText())
} else {
JSONObject()
}
val safeMode = settings.optJSONObject("safeMode") ?: JSONObject()
val currentState = safeMode.optBoolean("enabled", false)
val newState = !currentState
safeMode.put("enabled", newState)
if (newState && themeFile.exists()) {
val theme = JSONObject(themeFile.readText())
val themeId = theme.optString("id")
if (themeId.isNotEmpty()) {
safeMode.put("currentThemeId", themeId)
themeFile.delete()
}
}
settings.put("safeMode", safeMode)
settingsFile.writeText(settings.toString())
Toast.makeText(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}", Toast.LENGTH_SHORT).show()
reloadApp()
} catch (e: Exception) {
Log.e("Error toggling safe mode: ${e.message}")
showError(context, "Failed to toggle safe mode", e.message)
}
}
private fun refetchBundle(context: Context) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js")
val backupFile = File(pyoncordDir, "bundle.js.backup")
if (bundleFile.exists()) {
backupFile.delete()
bundleFile.renameTo(backupFile)
Log.e("Bundle moved to backup")
}
reloadApp()
} catch (e: Exception) {
Log.e("Error refetching bundle: ${e.message}")
showError(context, "Failed to refetch bundle", e.message)
}
}
private fun resetBundle(context: Context) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js")
val configFile = File(pyoncordDir, "loader_config.json")
bundleFile.delete()
// Reset config
if (configFile.exists()) {
val config = JSONObject(configFile.readText())
config.put("customLoadUrlEnabled", false)
config.put("customLoadUrl", "http://localhost:4040/ShiggyCord.js")
configFile.writeText(config.toString())
}
reloadApp()
} catch (e: Exception) {
Log.e("Error resetting bundle: ${e.message}")
showError(context, "Failed to reset bundle", e.message)
}
}
private fun loadCustomBundle(context: Context) {
val input = EditText(context).apply {
hint = "http://localhost:4040/ShiggyCord.js"
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
}
AlertDialog.Builder(context)
.setTitle("Load Custom Bundle")
.setMessage("Enter the URL for your custom bundle:")
.setView(input)
.setPositiveButton("Load") { _, _ ->
val urlString = input.text.toString().trim()
if (urlString.isEmpty()) {
showError(context, "Invalid URL", "Please enter a URL")
return@setPositiveButton
}
validateAndLoadBundle(context, urlString)
}
.setNegativeButton("Cancel", null)
.show()
}
private fun validateAndLoadBundle(context: Context, urlString: String) {
scope.launch(Dispatchers.IO) {
try {
val url = URL(urlString)
if (url.protocol != "http" && url.protocol != "https") {
withContext(Dispatchers.Main) {
showError(context, "Invalid URL", "URL must start with http:// or https://")
}
return@launch
}
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10000
connection.readTimeout = 10000
val responseCode = connection.responseCode
if (responseCode != 200) {
withContext(Dispatchers.Main) {
showError(context, "Error", "Server returned error $responseCode")
}
return@launch
}
val contentType = connection.contentType
if (contentType != null && !contentType.contains("javascript") && !contentType.contains("text")) {
withContext(Dispatchers.Main) {
showError(context, "Invalid Content", "URL must point to a JavaScript file")
}
return@launch
}
// Save custom bundle URL
withContext(Dispatchers.Main) {
setCustomBundleURL(context, urlString)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showError(context, "Connection Error", "Could not reach URL: ${e.message}")
}
}
}
}
private fun setCustomBundleURL(context: Context, url: String) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val configFile = File(pyoncordDir, "loader_config.json")
val config = if (configFile.exists()) {
JSONObject(configFile.readText())
} else {
JSONObject()
}
config.put("customLoadUrlEnabled", true)
config.put("customLoadUrl", url)
configFile.writeText(config.toString())
File(pyoncordDir, "bundle.js").delete()
Toast.makeText(context, "Custom bundle set", Toast.LENGTH_SHORT).show()
reloadApp()
} catch (e: Exception) {
Log.e("Error setting custom bundle URL: ${e.message}")
showError(context, "Failed to save configuration", e.message)
}
}
private fun getPyoncordDirectory(context: Context): File {
val dir = File(context.filesDir, "pyoncord")
if (!dir.exists()) {
dir.mkdirs()
}
return dir
}
private fun showError(context: Context, title: String, message: String?) {
AlertDialog.Builder(context)
.setTitle(title)
.setMessage(message ?: "An unknown error occurred")
.setPositiveButton("OK", null)
.show()
}
override fun onContext(context: Context) {
}
}

View file

@ -0,0 +1,79 @@
package ShiggyXposed.xposed.modules
import android.content.res.AssetManager
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.callbacks.XC_LoadPackage
import 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 kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.File
// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule if needed
class PayloadGlobalModule(private val modules: List<Module>) : Module() {
private companion object {
const val GLOBAL_NAME = "__PYON_LOADER__"
}
private fun getPayloadString(): String = JSON.encodeToString(
buildJsonObject {
put("loaderName", Constants.LOADER_NAME)
put("loaderVersion", BuildConfig.VERSION_NAME)
@Suppress("DEPRECATION") for (module in modules) module.buildPayload(this)
})
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
val catalystInstance = classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl")
val 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,124 @@
package ShiggyXposed.xposed.modules
import android.content.Context
import android.util.AtomicFile
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils.Log
import ShiggyXposed.xposed.modules.bridge.BridgeModule
import java.io.*
/**
* Manages the states of plugins, allowing reading and writing of plugin states to a file.
*
* ## Methods
*
* - `Shiggy.plugins.states.read(): { flags: { [pluginId: string]: number } }`
* - Reads the current plugin states from the file and returns them as a map.
*
* - `Shiggy.plugins.states.write(flags: { [pluginId: string]: number }): void`
* - Writes the provided plugin states to the file.
*/
class PluginsModule : Module() {
private companion object {
const val DATA_DIR = "Shiggy/plugins"
const val STATES_FILE = "states"
}
private lateinit var states: PluginStates
override fun onContext(context: Context) = with(context) {
val dataDir = File(filesDir, DATA_DIR)
val statesFile = File(
dataDir, STATES_FILE
).apply { asFile() }
BridgeModule.registerMethod("Shiggy.plugins.states.read") {
if (::states.isInitialized) states.toMap() else
PluginStates.loadFromFileOrNull(statesFile)?.let {
states = it
it.toMap()
}
}
BridgeModule.registerMethod("Shiggy.plugins.states.write") {
val (flags) = it
@Suppress("UNCHECKED_CAST")
states = PluginStates(
flags as Map<String, Double>
).apply { saveToFile(statesFile) }
Log.i("Plugin states saved: ${statesFile.absolutePath}")
}
}
}
private class UnsupportedPluginStatesVersionException(version: Int) :
Throwable("Unsupported plugin states version: $version")
data class PluginStates(
val flags: Map<String, Double>
) {
fun saveToFile(file: File) {
val atomic = AtomicFile(file)
var fos: FileOutputStream? = null
try {
fos = atomic.startWrite()
val out = DataOutputStream(BufferedOutputStream(fos))
out.writeInt(CURRENT_VERSION)
// Write flags
out.writeInt(flags.size)
for ((id, flags) in flags) {
out.writeUTF(id)
out.writeInt(flags.toInt())
}
out.flush()
atomic.finishWrite(fos)
} catch (t: Throwable) {
if (fos != null) atomic.failWrite(fos)
throw t
}
}
fun toMap(): Map<String, Any> = mapOf("flags" to flags)
companion object {
const val CURRENT_VERSION = 1
fun loadFromFileOrNull(file: File): PluginStates? {
if (!file.exists() || file.length() <= 0L) return null
val atomic = AtomicFile(file)
try {
DataInputStream(BufferedInputStream(atomic.openRead())).use { input ->
when (val version = input.readInt()) {
1 -> return loadV1FromFileOrNull(input)
else -> throw UnsupportedPluginStatesVersionException(version)
}
}
} catch (e: UnsupportedPluginStatesVersionException) {
Log.i(e.message!!)
} catch (e: EOFException) {
Log.e("Plugin states corrupt: ${e.message}")
} catch (e: IOException) {
Log.e("Failed to read plugin states: ${e.message}")
}
runCatching { file.delete() }
return null
}
private fun loadV1FromFileOrNull(input: DataInputStream): PluginStates {
val flagsSize = input.readInt()
val flags = mutableMapOf<String, Double>()
repeat(flagsSize) {
flags[input.readUTF()] = input.readInt().toDouble()
}
return PluginStates(flags)
}
}
}

View file

@ -0,0 +1,152 @@
package ShiggyXposed.xposed.modules
import android.app.Activity
import android.app.AlertDialog
import android.content.Context
import android.util.AtomicFile
import android.widget.Toast
import androidx.core.util.writeBytes
import de.robv.android.xposed.callbacks.XC_LoadPackage
import ShiggyXposed.xposed.Constants
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils
import ShiggyXposed.xposed.Utils.Companion.JSON
import ShiggyXposed.xposed.Utils.Companion.reloadApp
import ShiggyXposed.xposed.Utils.Log
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import java.io.File
import java.lang.ref.WeakReference
@Serializable
data class CustomLoadUrl(
val enabled: Boolean = false, val url: String = ""
)
@Serializable
data class LoaderConfig(
val customLoadUrl: CustomLoadUrl = CustomLoadUrl()
)
/**
* Module that updates the JS bundle by downloading it from a remote URL.
*
* Shows dialogs when failed allowing retry.
*/
object UpdaterModule : Module() {
private lateinit var config: LoaderConfig
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var lastActivity: WeakReference<Activity>? = null
private lateinit var cacheDir: File
private lateinit var bundle: File
private lateinit var etag: File
private const val TIMEOUT_CACHED = 5000L
private const val TIMEOUT = 10000L
private const val ETAG_FILE = "etag.txt"
private const val CONFIG_FILE = "loader.json"
private const val DEFAULT_BUNDLE_URL =
"https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/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())
}
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) {
AlertDialog.Builder(activity).setTitle("ShiggyCord Update Successful")
.setMessage("A reload is required for changes to take effect.")
.setPositiveButton("Reload") { dialog, _ ->
reloadApp()
dialog.dismiss()
}.setCancelable(false).show()
}
}
}
HttpStatusCode.NotModified -> {
Log.i("Server responded with 304, no changes")
}
else -> {
throw ResponseException(response, "Received status: ${response.status}")
}
}
}
} catch (e: Throwable) {
Log.e("Failed to download script", e)
showErrorDialog(e)
}
}
override fun onActivity(activity: Activity) {
lastActivity = WeakReference(activity)
}
fun showErrorDialog(e: Throwable) {
val activity = lastActivity?.get() ?: return
}
fun resetLoaderConfig(context: Context) {
val filesDir = File(context.dataDir, Constants.FILES_DIR)
val config = File(filesDir, CONFIG_FILE)
if (config.exists()) config.delete()
}
}

View file

@ -0,0 +1,230 @@
package ShiggyXposed.xposed.modules.appearance
import android.content.res.AssetManager
import android.graphics.Typeface
import android.graphics.Typeface.CustomFallbackBuilder
import android.graphics.fonts.Font
import android.graphics.fonts.FontFamily
import android.os.Build
import de.robv.android.xposed.XC_MethodReplacement
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import ShiggyXposed.xposed.Constants
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils.Companion.JSON
import ShiggyXposed.xposed.Utils.Log
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.put
import java.io.File
import java.io.IOException
@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

@ -1,11 +1,11 @@
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 ShiggyXposed.xposed.Module
import kotlinx.serialization.json.*
class SysColorsModule : Module() {
@ -19,17 +19,14 @@ class SysColorsModule : Module() {
builder.apply {
put("isSysColorsSupported", isSupported())
if (isSupported())
putJsonObject("sysColors") {
if (isSupported()) putJsonObject("sysColors") {
for (accent in accents) putJsonArray(accent) {
for (shade in shades) {
val colorName = "system_" + accent + "_" + shade
val colorResourceId =
runCatching {
val colorResourceId = runCatching {
color::class.java.getField(colorName).getInt(null)
}
.getOrElse { 0 }
}.getOrElse { 0 }
add(convertToColor(colorResourceId))
}

View file

@ -1,20 +1,23 @@
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 ShiggyXposed.xposed.Constants
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils.Companion.JSON
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import java.io.File
@Serializable data class Author(val name: String, val id: String? = null)
@Serializable
data class Author(
val name: String, val id: String? = null
)
@Serializable
data class ThemeData(
@ -26,7 +29,10 @@ data class ThemeData(
val rawColors: Map<String, String>? = null
)
@Serializable data class Theme(val id: String, val selected: Boolean, val data: ThemeData)
@Serializable
data class Theme(
val id: String, val selected: Boolean, val data: ThemeData
)
class ThemesModule : Module() {
private lateinit var param: XC_LoadPackage.LoadPackageParam
@ -48,9 +54,7 @@ class ThemesModule : Module() {
}
private fun String.fromScreamingSnakeToCamelCase() =
this.split("_").joinToString("") { it ->
it.lowercase().replaceFirstChar { it.uppercase() }
}
this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } }
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
param = packageParam
@ -66,10 +70,7 @@ class ThemesModule : Module() {
}
private fun getTheme(): Theme? {
val themeFile =
File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply {
asFile()
}
val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { asFile() }
if (!themeFile.isValidish()) return null
return try {
@ -106,27 +107,21 @@ class ThemesModule : Module() {
// If there's any rawColors value, hook the color getter
if (!theme.data.rawColors.isNullOrEmpty()) {
val getColorCompat =
themeManager.getDeclaredMethod(
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 getColorCompatLegacy = themeManager.getDeclaredMethod(
"getColorCompat", Context::class.java, Int::class.javaPrimitiveType
)
val patch =
MethodHookBuilder().run {
val patch = MethodHookBuilder().run {
before {
val arg1 = args[0]
val resources =
if (arg1 is Context) arg1.resources else (arg1 as Resources)
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]
@ -153,8 +148,13 @@ class ThemesModule : Module() {
private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
try {
themeClass.getDeclaredMethod(methodName).let { method ->
method.hook { before { result = themeValue } }
}
} catch (_: NoSuchMethodException) {}
method.hook {
before {
result = themeValue
}
}
}
} catch (_: NoSuchMethodException) {
}
}
}

View file

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

View file

@ -0,0 +1,149 @@
package ShiggyXposed.xposed.modules.bridge
import de.robv.android.xposed.callbacks.XC_LoadPackage
import ShiggyXposed.xposed.BuildConfig
import ShiggyXposed.xposed.Constants
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils.Log
import java.lang.reflect.Method
/**
* See for possible return types:
* https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19
*
* You may return a [Unit] and the resulting value will be `null`.
*/
typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any?
typealias BridgeMethodArgs = ArrayList<Any>
/**
* A module that exposes a bridge for calling methods from JavaScript.
*
* To call a method, pass an object with the following structure to a hooked method:
* ```js
* {
* Shiggy: {
* method: "method.name",
* args: [arg1, arg2, ...]
* }
* }
* ```
*
* For methods that accept additional arguments, only the first object argument is used for the bridge call.
*
* The result will be an object with either a `result` or `error` key:
* ```js
* {
* result: ... // The return value of the method
* }
* ```
* or
* ```js
* {
* error: "Error message"
* }
* ```
*/
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 = "Shiggy"
private const val METHOD_NAME_KEY = "method"
private const val METHOD_ARGS_KEY = "args"
private val methods: MutableMap<String, BridgeMethodCallback> = mutableMapOf("Shiggy.info" to {
mapOf(
"name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE
)
}, "Shiggy.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,
)
})
/**
* 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.stackTraceToString())
}
private fun getBridgeCallData(hashMap: HashMap<String, Any?>): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
@Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
data ?: return null
@Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String
val method = methods[name]
method ?: throw Error("Method not registered: $name")
@Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs
return Pair(method, args)
}
}

View file

@ -0,0 +1,34 @@
package ShiggyXposed.xposed.modules.no_track
import android.content.Context
import de.robv.android.xposed.callbacks.XC_LoadPackage
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils.Log
/**
* Hooks Discord's crash reporting to disable Sentry initialization.
* While Discord doesn't have Sentry auto-initialization on by default, we still hook the content provider just to be safe.
*/
class BlockCrashReportingModule : Module() {
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
val crashReportingClass = classLoader.safeLoadClass("com.discord.crash_reporting.CrashReporting")
crashReportingClass?.hookMethod(
"init", Context::class.java, String::class.java
) {
before {
Log.i("Blocked CrashReporting initialization")
result = null
}
}
val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider")
sentryInitProviderClass?.hookMethod("onCreate") {
before {
Log.i("Blocked SentryInitProvider initialization")
result = true
}
}
return@with
}
}

View file

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

View file

@ -1,70 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,510 +0,0 @@
package ShiggyXposed.xposed.modules
import ShiggyXposed.xposed.Constants
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils
import ShiggyXposed.xposed.Utils.Companion.reloadApp
import ShiggyXposed.xposed.Utils.Log
import android.app.AlertDialog
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Switch
import android.widget.TextView
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 kotlinx.serialization.json.*
/**
* Replacement for the old recovery/dev dialog. Shows a dark-themed dialog on the main thread with:
* - Load from custom URL toggle + URL input (persists to files/pyoncord/loader.json)
* - Save & Reload, Delete bundle, Close actions
*
* This simplified version removes the Safe Mode toggle and the per-addon list. Save only persists
* the custom URL configuration.
*/
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"
)
// Enable developer support (so shake/dev menu becomes available)
XposedBridge.hookMethod(
getUseDeveloperSupportMethod,
object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
param.result = true
}
}
)
// Replace reload with app restart to avoid remote dev server dependency
XposedBridge.hookMethod(
handleReloadJSMethod,
object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
reloadApp()
param.result = null
}
}
)
// Intercept showDevOptionsDialog and replace with our dialog
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.message}",
ex
)
XposedBridge.log(
"ShiggyXposed: Failed to show dev options dialog: ${android.util.Log.getStackTraceString(ex)}"
)
}
// Prevent original menu from showing
param.result = null
}
}
)
} catch (e: Exception) {
Log.e("Error in LogBoxModule.onLoad: ${e.message}", e)
XposedBridge.log(
"ShiggyXposed: LogBoxModule.onLoad error: ${android.util.Log.getStackTraceString(e)}"
)
}
return@with
}
private fun showRecoveryAlert(context: Context) {
// Always perform UI operations on the main thread to avoid runtime exceptions
Handler(Looper.getMainLooper()).post {
try {
val themed = context
// Prepare paths
val filesDir =
File(packageParam.appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
val configFile = File(filesDir, "loader.json")
val preloadsDir =
File(filesDir, HookScriptLoaderModule.PRELOADS_DIR).apply { mkdirs() }
// Read existing loader.json (guarded) for custom URL
var customEnabled = false
var customUrl = ""
try {
if (configFile.exists()) {
val je = Utils.JSON.parseToJsonElement(configFile.readText())
val custom = je.jsonObject["customLoadUrl"]?.jsonObject
if (custom != null) {
customEnabled = custom["enabled"]?.jsonPrimitive?.booleanOrNull ?: false
customUrl = custom["url"]?.jsonPrimitive?.contentOrNull ?: ""
}
}
} catch (t: Throwable) {
Log.e("Failed to parse loader.json: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: loader.json parse failed: ${android.util.Log.getStackTraceString(t)}"
)
}
// Build the dialog UI programmatically.
val padding = (themed.resources.displayMetrics.density * 16).toInt()
val container =
LinearLayout(themed).apply {
orientation = LinearLayout.VERTICAL
setPadding(padding, padding, padding, padding)
setBackgroundColor(0xFF121212.toInt())
}
// Helper to create a labeled switch row
fun addSwitchRow(labelText: String, checked: Boolean): Pair<TextView, Switch> {
val rowLp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
val row =
LinearLayout(themed).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = rowLp
}
val tvLp =
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
val tv =
TextView(themed).apply {
text = labelText
setTextColor(0xFFFFFFFF.toInt())
textSize = 15f
layoutParams = tvLp
}
val swLp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
val sw =
Switch(themed).apply {
isChecked = checked
layoutParams = swLp
}
row.addView(tv)
row.addView(sw)
container.addView(row)
return Pair(tv, sw)
}
// Create rows: custom URL toggle
val customRow = addSwitchRow("Load from custom URL", false)
val customSwitch = customRow.second
// Confirmation modal helper (keeps previous messaging/behavior)
fun showReloadConfirmModal(parentCtx: Context, turningOn: Boolean) {
try {
val dialogContainer =
LinearLayout(parentCtx).apply {
orientation = LinearLayout.VERTICAL
val pad =
(parentCtx.resources.displayMetrics.density * 16)
.toInt()
setPadding(pad, pad, pad, pad)
setBackgroundColor(0xFF121212.toInt())
}
val message =
TextView(parentCtx).apply {
text =
if (turningOn) {
"All add-ons will be temporarily disabled upon reload."
} else {
"All add-ons will load normally."
}
setTextColor(0xFFFFFFFF.toInt())
textSize = 14f
setPadding(0, 0, 0, 8)
}
dialogContainer.addView(message)
val buttonsRow =
LinearLayout(parentCtx).apply {
orientation = LinearLayout.HORIZONTAL
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
layoutParams = lp
}
val reloadNowBtn =
Button(parentCtx).apply {
text = "Reload Now"
setTextColor(0xFF000000.toInt())
setBackgroundColor(0xFFBB86FC.toInt())
val wlp =
LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
)
wlp.marginEnd = 8
layoutParams = wlp
setOnClickListener {
try {
reloadApp()
} catch (_: Throwable) {}
}
}
val laterBtn =
Button(parentCtx).apply {
text = "Later"
setTextColor(0xFFFFFFFF.toInt())
val wlp =
LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
)
layoutParams = wlp
}
buttonsRow.addView(
reloadNowBtn,
LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
)
.apply { marginEnd = 8 }
)
buttonsRow.addView(
laterBtn,
LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
)
)
dialogContainer.addView(buttonsRow)
val dlg = AlertDialog.Builder(parentCtx).setView(dialogContainer).create()
laterBtn.setOnClickListener { dlg.dismiss() }
dlg.show()
} catch (t: Throwable) {
Log.e("Failed to show reload confirm modal: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: Reload confirm modal failed: ${android.util.Log.getStackTraceString(t)}"
)
try {
AlertDialog.Builder(parentCtx)
.setTitle("Reload now?")
.setMessage(
if (turningOn)
"All add-ons will be temporarily disabled upon reload."
else "All add-ons will load normally."
)
.setPositiveButton("Reload Now") { _, _ -> reloadApp() }
.setNegativeButton("Later", null)
.show()
} catch (_: Throwable) {}
}
}
// Safe mode toggle removed; no-op.
// URL label + input
val lblUrl =
TextView(themed).apply {
text = "Custom bundle URL"
setTextColor(0xFFFFFFFF.toInt())
textSize = 13f
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
lp.topMargin = (themed.resources.displayMetrics.density * 8).toInt()
layoutParams = lp
}
val urlInput =
EditText(themed).apply {
hint = "https://example.com/bundle.js"
setTextColor(0xFFFFFFFF.toInt())
setHintTextColor(0xFFBDBDBD.toInt())
setBackgroundColor(0xFF1E1E1E.toInt())
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
lp.topMargin = (themed.resources.displayMetrics.density * 6).toInt()
layoutParams = lp
visibility = View.GONE
}
container.addView(lblUrl)
container.addView(urlInput)
// Buttons row - weighted buttons for consistent spacing
val buttonsRow =
LinearLayout(themed).apply {
orientation = LinearLayout.HORIZONTAL
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
lp.topMargin = (themed.resources.displayMetrics.density * 12).toInt()
layoutParams = lp
}
fun createButton(text: String): Button {
return Button(themed).apply {
this.text = text
setAllCaps(false)
setTextColor(0xFFFFFFFF.toInt())
val bLp =
LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
)
bLp.marginEnd = (themed.resources.displayMetrics.density * 8).toInt()
layoutParams = bLp
minHeight = (themed.resources.displayMetrics.density * 36).toInt()
setBackgroundColor(0x00000000)
}
}
val deleteBtn = createButton("Delete bundle")
val closeBtn = createButton("Close")
val saveBtn =
createButton("Save & Reload").apply {
setBackgroundColor(0xFFBB86FC.toInt())
setTextColor(0xFF000000.toInt())
}
// Add Delete and Close to the top row (equal width)
buttonsRow.addView(deleteBtn)
buttonsRow.addView(closeBtn)
container.addView(buttonsRow)
// Save button in its own row (full-width look)
val saveRow =
LinearLayout(themed).apply {
orientation = LinearLayout.HORIZONTAL
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
lp.topMargin = (themed.resources.displayMetrics.density * 8).toInt()
layoutParams = lp
}
val saveLp =
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
saveBtn.layoutParams = saveLp
saveBtn.minHeight = (themed.resources.displayMetrics.density * 40).toInt()
saveRow.addView(saveBtn)
container.addView(saveRow)
// Initialize UI state
customSwitch.isChecked = customEnabled
urlInput.setText(customUrl)
urlInput.visibility = if (customEnabled) View.VISIBLE else View.GONE
// Toggle URL input visibility when switching custom URL on/off
customSwitch.setOnCheckedChangeListener { _, checked ->
urlInput.visibility = if (checked) View.VISIBLE else View.GONE
}
// Save button: only persist custom URL config and then reload
saveBtn.setOnClickListener {
try {
val enabled = customSwitch.isChecked
val url = urlInput.text?.toString() ?: ""
val escapedUrl = url.replace("\\", "\\\\").replace("\"", "\\\"")
val json =
"{\"customLoadUrl\":{\"enabled\":$enabled,\"url\":\"$escapedUrl\"}}"
runCatching { configFile.writeText(json) }.onFailure { t ->
Log.e("Failed to write loader.json: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: write loader.json failed: ${android.util.Log.getStackTraceString(t)}"
)
}
} catch (t: Throwable) {
Log.e("Failed to save loader.json: ${t.message}", t)
}
reloadApp()
}
// Delete bundle
deleteBtn.setOnClickListener {
try {
val bundleFile =
File(
packageParam.appInfo.dataDir,
"${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
)
if (bundleFile.exists()) bundleFile.delete()
} catch (t: Throwable) {
Log.e("Failed to delete bundle: ${t.message}", t)
}
reloadApp()
}
// Close button dismisses the created dialog
val dlg = AlertDialog.Builder(themed).setView(container).create()
dlg.show()
closeBtn.setOnClickListener { dlg.dismiss() }
} catch (e: Throwable) {
// Log and fallback to old simple dialog
Log.e("Dialog failed, falling back: ${e.message}", e)
XposedBridge.log(
"ShiggyXposed: Dialog failed: ${android.util.Log.getStackTraceString(e)}"
)
try {
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()
} catch (t: Throwable) {
Log.e("Fallback dialog also failed: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: Fallback dialog failed: ${android.util.Log.getStackTraceString(t)}"
)
}
}
}
}
}

View file

@ -1,108 +0,0 @@
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

@ -1,148 +0,0 @@
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/latest/download/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) {
var timeoutMs = TIMEOUT_CACHED
if (!bundle.exists()) {
timeoutMs = TIMEOUT
}
timeout { requestTimeoutMillis = timeoutMs }
}
}
when (response.status) {
HttpStatusCode.OK -> {
val bytes: ByteArray = response.body()
AtomicFile(bundle).writeBytes(bytes)
val newTag = response.headers[HttpHeaders.ETag]
if (!newTag.isNullOrEmpty()) etag.writeText(newTag)
else etag.delete()
Log.i("Bundle updated (${bytes.size} bytes)")
// This is a retry, so we show a dialog
if (activity != null) {
withContext(Dispatchers.Main) {}
}
}
HttpStatusCode.NotModified -> {
Log.i("Server responded with 304, no changes")
}
else -> {
throw ResponseException(
response,
"Received status: ${response.status}"
)
}
}
}
} catch (e: Throwable) {
Log.e("Failed to download script", e)
error = e
}
}
}
override fun onActivity(activity: Activity) {
error ?: return
error = null
downloadScript(activity)
}
}

View file

@ -1,271 +0,0 @@
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

@ -1,52 +0,0 @@
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

@ -1,208 +0,0 @@
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 readableMapToHashMapMethod: Method
private lateinit var argumentsMakeNative: Method
companion object {
private const val CALL_DATA_KEY = "revenge"
private const val METHOD_NAME_KEY = "method"
private const val METHOD_ARGS_KEY = "args"
private fun File.openFileGuarded() {
if (!this.exists()) throw Error("Path does not exist: $path")
if (!this.isFile) throw Error("Path is not a file: $path")
}
private val methods: MutableMap<String, BridgeMethodCallback> =
mutableMapOf(
"revenge.info" to
{
mapOf(
"name" to Constants.LOADER_NAME,
"version" to BuildConfig.VERSION_CODE
)
},
"revenge.test" to
{
mapOf(
"string" to "string",
"number" to 7256,
"array" to listOf("testing", 527737, listOf(true)),
"object" to mapOf("nested" to true),
"boolean" to false,
"args" to it,
)
},
"revenge.fs.delete" to
{
val (path) = it
File(path as String).run {
if (this.isDirectory) this.deleteRecursively()
else this.delete()
}
},
"revenge.fs.exists" to
{
val (path) = it
File(path as String).exists()
},
"revenge.fs.read" to
{ it ->
val (path) = it
val file = File(path as String).apply { openFileGuarded() }
file.bufferedReader().use { it.readText() }
},
"revenge.fs.write" to
{ it ->
val (path, contents) = it
val file = File(path as String).apply { openFileGuarded() }
file.writeText(contents as String)
}
)
/**
* Registers a bridge method that can be called from JavaScript.
*
* If the method is already registered, it will be overwritten.
*
* @param name The name of the method to register.
* @param callback The callback to invoke when the method is called.
*/
fun registerMethod(name: String, callback: BridgeMethodCallback) {
if (methods.containsKey(name))
Log.w("Bridge method already exists and will be overridden: $name")
methods[name] = callback
}
}
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
with(packageParam) {
val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments")
val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap")
val promise = classLoader.loadClass("com.facebook.react.bridge.Promise")
val promiseResolve = promise.method("resolve", Object::class.java)
argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java)
readableMapGetString = readableMap.method("getString", String::class.java)
readableMapToHashMapMethod = readableMap.method("toHashMap")
classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager").hookMethod(
"getBBox",
Double::class.javaObjectType,
readableMap
) {
before {
callBridgeMethod(readableMapToHashMapConverter(args[1]!!))?.let {
result = it.toNativeObject()
}
}
}
classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule").hookMethod(
"readAsDataURL",
readableMap,
promise
) {
before {
// Avoid shadowing the outer `readableMap` and `promise` variables
val (readableMapArg, promiseArg) = args
callBridgeMethod(readableMapToHashMapConverter(readableMapArg!!))?.let {
promiseResolve.invoke(promiseArg, it.toNativeObject())
result = null
}
}
}
}
}
private fun Any?.toNativeObject(): Any? =
argumentsMakeNative.invoke(
null,
when (this) {
Unit -> null
else -> this
}
)
private fun readableMapToHashMapConverter(map: Any): HashMap<String, Any?> {
@Suppress("UNCHECKED_CAST")
return readableMapToHashMapMethod.invoke(map) as HashMap<String, Any?>
}
private fun callBridgeMethod(hashMap: HashMap<String, Any?>): Map<String, Any?>? {
try {
val pair = getBridgeCallData(hashMap) ?: return null
val (method, args) = pair
val ret = method(args).toNativeObject()
return mapOf("result" to ret)
} catch (e: Throwable) {
return mapOf("error" to e.toString())
}
}
private fun getBridgeCallData(
hashMap: HashMap<String, Any?>
): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
@Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
data ?: return null
@Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String
val method = methods[name]
method ?: throw Error("Method not registered: $name")
@Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs
return Pair(method, args)
}
}

View file

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