Pull KettuXposed and rename it fully
This commit is contained in:
parent
7a42f2eaa5
commit
5f26b75039
30 changed files with 1895 additions and 1644 deletions
|
|
@ -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 = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
107
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt
Normal file
107
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
58
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt
Normal file
58
app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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 } }
|
||||
method.hook {
|
||||
before {
|
||||
result = themeValue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: NoSuchMethodException) {
|
||||
}
|
||||
} catch (_: NoSuchMethodException) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
rootProject.name = "KettuXposed"
|
||||
rootProject.name = "ShiggyXposed"
|
||||
include(":app")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue