rebase(xposed): rebase onto revengeXposed
This commit is contained in:
parent
d39f81f34e
commit
9970bc5751
42 changed files with 1209 additions and 838 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -13,6 +13,3 @@ build
|
|||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
.idea/AndroidProjectSystem.xml
|
||||
/app/release
|
||||
/app/build
|
||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
26
.idea/appInsightsSettings.xml
generated
26
.idea/appInsightsSettings.xml
generated
|
|
@ -1,26 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="PLACEHOLDER" />
|
||||
<option name="mobileSdkAppId" value="" />
|
||||
<option name="projectId" value="" />
|
||||
<option name="projectNumber" value="" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
115
.idea/codeStyles/Project.xml
generated
115
.idea/codeStyles/Project.xml
generated
|
|
@ -1,121 +1,10 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<option name="AUTODETECT_INDENTS" value="false" />
|
||||
<option name="SOFT_MARGINS" value="120" />
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
|
|
|
|||
10
.idea/deploymentTargetDropDown.xml
generated
10
.idea/deploymentTargetDropDown.xml
generated
|
|
@ -1,10 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<value>
|
||||
<entry key="app">
|
||||
<State />
|
||||
</entry>
|
||||
</value>
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
|
|
@ -4,14 +4,6 @@
|
|||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-05-05T09:01:33.514844700Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\Cocobo\.android\avd\Medium_Phone_API_VanillaIceCream.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
|
|
|||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
|
|
@ -13,7 +13,6 @@
|
|||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
|
|
|||
30
.idea/jarRepositories.xml
generated
30
.idea/jarRepositories.xml
generated
|
|
@ -1,30 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RemoteRepositoriesConfiguration">
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Maven Central repository" />
|
||||
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="jboss.community" />
|
||||
<option name="name" value="JBoss Community repository" />
|
||||
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="BintrayJCenter" />
|
||||
<option name="name" value="BintrayJCenter" />
|
||||
<option name="url" value="https://jcenter.bintray.com/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="Google" />
|
||||
<option name="name" value="Google" />
|
||||
<option name="url" value="https://dl.google.com/dl/android/maven2/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="MavenRepo" />
|
||||
<option name="name" value="MavenRepo" />
|
||||
<option name="url" value="https://repo.maven.apache.org/maven2/" />
|
||||
</remote-repository>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/kotlinc.xml
generated
6
.idea/kotlinc.xml
generated
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.1.10" />
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/misc.xml
generated
9
.idea/misc.xml
generated
|
|
@ -1,9 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
<file type="web" url="file://$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"java.configuration.updateBuildConfiguration": "disabled"
|
||||
}
|
||||
BIN
Images/kettu.png
BIN
Images/kettu.png
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
28
README.md
28
README.md
|
|
@ -1,28 +0,0 @@
|
|||
<div align="center">
|
||||
|
||||
<img src="Images/kettu.png" alt="Kettu logo" width="200px" style="border-radius: 50%" />
|
||||
|
||||
# KettuXposed
|
||||
|
||||
Easily install Kettu on your rooted device!
|
||||
|
||||
[](https://github.com/C0C0B01/KettuXposed/releases/latest)
|
||||
|
||||
---
|
||||
|
||||
<br>
|
||||
|
||||
&color=blue)
|
||||
&color=blue)
|
||||
|
||||
<br>
|
||||
|
||||
</div>
|
||||
|
||||
## Contributing
|
||||
|
||||
This is an open-source project, you can do so without any programming.
|
||||
|
||||
Here are a few things you can do:
|
||||
|
||||
- [Test and report issues](https://github.com/C0C0B01/KettuXposed/issues/new/choose)
|
||||
|
|
@ -10,21 +10,21 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId = "cocobo1.pupu.xposed"
|
||||
minSdk = 22
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 1130
|
||||
versionName = "1.1.3"
|
||||
versionCode = 1200
|
||||
versionName = "1.2.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
release {
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
|
|
@ -39,31 +39,12 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven("https://jitpack.io")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("de.robv.android.xposed:api:82")
|
||||
compileOnly(libs.xposed)
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
|
||||
implementation(libs.libunbound)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
implementation(libs.kotlin.reflect)
|
||||
|
||||
implementation(libs.yuki.api)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
implementation("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.0")
|
||||
implementation("io.ktor:ktor-client-cio:3.3.0")
|
||||
}
|
||||
|
|
|
|||
BIN
app/release/app-release.apk
Normal file
BIN
app/release/app-release.apk
Normal file
Binary file not shown.
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
BIN
app/release/baselineProfiles/0/app-release.dm
Normal file
Binary file not shown.
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
BIN
app/release/baselineProfiles/1/app-release.dm
Normal file
Binary file not shown.
37
app/release/output-metadata.json
Normal file
37
app/release/output-metadata.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"version": 3,
|
||||
"artifactType": {
|
||||
"type": "APK",
|
||||
"kind": "Directory"
|
||||
},
|
||||
"applicationId": "cocobo1.pupu.xposed",
|
||||
"variantName": "release",
|
||||
"elements": [
|
||||
{
|
||||
"type": "SINGLE",
|
||||
"filters": [],
|
||||
"attributes": [],
|
||||
"versionCode": 1200,
|
||||
"versionName": "1.2.0",
|
||||
"outputFile": "app-release.apk"
|
||||
}
|
||||
],
|
||||
"elementType": "File",
|
||||
"baselineProfiles": [
|
||||
{
|
||||
"minApi": 28,
|
||||
"maxApi": 30,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/1/app-release.dm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"minApi": 31,
|
||||
"maxApi": 2147483647,
|
||||
"baselineProfiles": [
|
||||
"baselineProfiles/0/app-release.dm"
|
||||
]
|
||||
}
|
||||
],
|
||||
"minSdkVersionForDexing": 24
|
||||
}
|
||||
|
|
@ -1,25 +1,25 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
tools:targetApi="31"
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
tools:targetApi="31"
|
||||
>
|
||||
<meta-data
|
||||
android:name="xposedmodule"
|
||||
android:value="true" />
|
||||
android:name="xposedmodule"
|
||||
android:value="true"/>
|
||||
<meta-data
|
||||
android:name="xposeddescription"
|
||||
android:value="An Xposed module to inject Kettu, a mod for Discord's mobile apps." />
|
||||
android:name="xposeddescription"
|
||||
android:value="Xposed module to inject Kettu into discord"/>
|
||||
<meta-data
|
||||
android:name="xposedminversion"
|
||||
android:value="82" />
|
||||
android:name="xposedminversion"
|
||||
android:value="82"/>
|
||||
<meta-data
|
||||
android:name="xposedscope"
|
||||
android:value="com.discord"
|
||||
android:name="xposedscope"
|
||||
android:value="com.discord"
|
||||
/>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package cocobo1.pupu.xposed
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.AndroidAppHelper
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import java.io.File
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class LogBoxModule: Module() {
|
||||
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
|
||||
|
||||
override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) {
|
||||
this@LogBoxModule.packageParam = packageParam
|
||||
|
||||
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
|
||||
val bridgeDevSupportManagerClass = classLoader.loadClass("com.facebook.react.devsupport.BridgeDevSupportManager")
|
||||
|
||||
val mReactInstanceDevHelperField = XposedHelpers.findField(bridgeDevSupportManagerClass, "mReactInstanceDevHelper")
|
||||
|
||||
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
|
||||
val handleReloadJSMethod = bridgeDevSupportManagerClass.methods.first { it.name == "handleReloadJS" }
|
||||
val showDevOptionsDialogMethod = bridgeDevSupportManagerClass.methods.first { it.name == "showDevOptionsDialog" }
|
||||
|
||||
var alertDialog: AlertDialog? = null
|
||||
|
||||
// This enables the LogBox and opens dev option on shake
|
||||
XposedBridge.hookMethod(
|
||||
getUseDeveloperSupportMethod,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
param.result = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Triggered on shake
|
||||
XposedBridge.hookMethod(
|
||||
showDevOptionsDialogMethod,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
try {
|
||||
if (alertDialog == null) {
|
||||
val mReactInstanceDevHelper =
|
||||
mReactInstanceDevHelperField.get(param.thisObject)
|
||||
val getCurrentActivityMethod =
|
||||
mReactInstanceDevHelper.javaClass.methods.first { it.name == "getCurrentActivity" }
|
||||
|
||||
val context =
|
||||
getCurrentActivityMethod.invoke(mReactInstanceDevHelper) as Context
|
||||
|
||||
alertDialog = showRecoveryAlert(context) {
|
||||
alertDialog = null
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Log.e("Kettu", "Failed to show dev options dialog: $ex")
|
||||
alertDialog = null
|
||||
}
|
||||
|
||||
// Ignore the original dev menu
|
||||
param.result = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Replace the method to direct relaunch the app instead of sending reload command to developer server
|
||||
XposedBridge.hookMethod(
|
||||
handleReloadJSMethod,
|
||||
object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
reloadApp()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return@with
|
||||
}
|
||||
|
||||
private fun showRecoveryAlert(context: Context, onClose: () -> Unit): AlertDialog {
|
||||
return AlertDialog.Builder(context)
|
||||
.setTitle("Kettu Recovery Options")
|
||||
.setItems(arrayOf("Reload", "Delete bundle.js")) { _, which ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
reloadApp()
|
||||
}
|
||||
1 -> {
|
||||
val bundleFile = File(packageParam.appInfo.dataDir, "cache/pyoncord/bundle.js")
|
||||
if (bundleFile.exists()) {
|
||||
bundleFile.delete()
|
||||
}
|
||||
|
||||
reloadApp()
|
||||
}
|
||||
}
|
||||
|
||||
onClose()
|
||||
}
|
||||
.setOnDismissListener {
|
||||
onClose()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun reloadApp() {
|
||||
val application = AndroidAppHelper.currentApplication()
|
||||
val intent = application.packageManager.getLaunchIntentForPackage(application.packageName)
|
||||
application.startActivity(Intent.makeRestartActivityTask(intent!!.component))
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
package cocobo1.pupu.xposed
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.res.AssetManager
|
||||
import android.content.res.Resources
|
||||
import android.util.Log
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import de.robv.android.xposed.IXposedHookLoadPackage
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
|
||||
@Serializable
|
||||
data class CustomLoadUrl(
|
||||
val enabled: Boolean,
|
||||
val url: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderConfig(
|
||||
val customLoadUrl: CustomLoadUrl
|
||||
)
|
||||
|
||||
class Main : IXposedHookLoadPackage {
|
||||
private val Modules: Array<Module> = arrayOf(
|
||||
ThemeModule(),
|
||||
SysColorsModule(),
|
||||
FontsModule(),
|
||||
LogBoxModule()
|
||||
)
|
||||
|
||||
fun buildLoaderJsonString(): String {
|
||||
val obj = buildJsonObject {
|
||||
put("loaderName", "KettuXposed")
|
||||
put("loaderVersion", BuildConfig.VERSION_NAME)
|
||||
|
||||
for (module in Modules) {
|
||||
module.buildJson(this)
|
||||
}
|
||||
}
|
||||
|
||||
return Json.encodeToString(obj)
|
||||
}
|
||||
|
||||
override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
|
||||
val reactActivity = runCatching {
|
||||
lpparam.classLoader.loadClass("com.discord.react_activities.ReactActivity")
|
||||
}.getOrElse { return } // Package is not our the target app, return
|
||||
|
||||
var activity: Activity? = null
|
||||
val onActivityCreateCallback = mutableSetOf<(activity: Activity) -> Unit>()
|
||||
|
||||
XposedBridge.hookMethod(reactActivity.getDeclaredMethod("onCreate", Bundle::class.java), object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
activity = param.thisObject as Activity
|
||||
onActivityCreateCallback.forEach { cb -> cb(activity!!) }
|
||||
onActivityCreateCallback.clear()
|
||||
}
|
||||
})
|
||||
|
||||
init(lpparam) { cb ->
|
||||
if (activity != null) cb(activity!!)
|
||||
else onActivityCreateCallback.add(cb)
|
||||
}
|
||||
}
|
||||
|
||||
private fun init(
|
||||
param: XC_LoadPackage.LoadPackageParam,
|
||||
onActivityCreate: ((activity: Activity) -> Unit) -> Unit
|
||||
) = with (param) {
|
||||
val catalystInstanceImpl = classLoader.loadClass("com.facebook.react.bridge.CatalystInstanceImpl")
|
||||
|
||||
for (module in Modules) module.onInit(param)
|
||||
|
||||
val loadScriptFromAssets = catalystInstanceImpl.getDeclaredMethod(
|
||||
"loadScriptFromAssets",
|
||||
AssetManager::class.java,
|
||||
String::class.java,
|
||||
Boolean::class.javaPrimitiveType
|
||||
).apply { isAccessible = true }
|
||||
|
||||
val loadScriptFromFile = catalystInstanceImpl.getDeclaredMethod(
|
||||
"loadScriptFromFile",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
Boolean::class.javaPrimitiveType
|
||||
).apply { isAccessible = true }
|
||||
|
||||
val setGlobalVariable = catalystInstanceImpl.getDeclaredMethod(
|
||||
"setGlobalVariable",
|
||||
String::class.java,
|
||||
String::class.java
|
||||
).apply { isAccessible = true }
|
||||
|
||||
val cacheDir = File(appInfo.dataDir, "cache/pyoncord").apply { mkdirs() }
|
||||
val filesDir = File(appInfo.dataDir, "files/pyoncord").apply { mkdirs() }
|
||||
|
||||
val preloadsDir = File(filesDir, "preloads").apply { mkdirs() }
|
||||
val bundle = File(cacheDir, "bundle.js")
|
||||
val etag = File(cacheDir, "etag.txt")
|
||||
|
||||
val configFile = File(filesDir, "loader.json")
|
||||
|
||||
val config = try {
|
||||
if (!configFile.exists()) throw Exception()
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
json.decodeFromString(configFile.readText())
|
||||
} catch (_: Exception) {
|
||||
LoaderConfig(
|
||||
customLoadUrl = CustomLoadUrl(
|
||||
enabled = false,
|
||||
url = "" // Not used
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val scope = MainScope()
|
||||
val httpJob = scope.async(Dispatchers.IO) {
|
||||
try {
|
||||
val client = HttpClient(CIO) {
|
||||
expectSuccess = true
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = if (bundle.exists()) 4000 else 20000
|
||||
}
|
||||
install(UserAgent) { agent = "KettuXposed" }
|
||||
}
|
||||
|
||||
val url =
|
||||
if (config.customLoadUrl.enabled) config.customLoadUrl.url
|
||||
else "https://raw.githubusercontent.com/C0C0B01/Kettu/refs/heads/dist/kettu.min.js"
|
||||
|
||||
Log.e("Kettu", "Fetching JS bundle from $url")
|
||||
|
||||
val response: HttpResponse = client.get(url) {
|
||||
headers {
|
||||
if (etag.exists() && bundle.exists()) {
|
||||
append(HttpHeaders.IfNoneMatch, etag.readText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bundle.writeBytes(response.body())
|
||||
if (response.headers["Etag"] != null) {
|
||||
etag.writeText(response.headers["Etag"]!!)
|
||||
}
|
||||
else if (etag.exists()) {
|
||||
// This is called when server does not return an E-tag, so clear em
|
||||
etag.delete()
|
||||
}
|
||||
|
||||
return@async
|
||||
} catch (e: RedirectResponseException) {
|
||||
if (e.response.status != HttpStatusCode.NotModified) throw e
|
||||
Log.e("Kettu", "Server responded with status code 304 - no changes to file")
|
||||
} catch (e: Throwable) {
|
||||
onActivityCreate { activity ->
|
||||
activity.runOnUiThread {
|
||||
Toast.makeText(
|
||||
activity.applicationContext,
|
||||
"Failed to fetch JS bundle, Kettu may not load!",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
Log.e("Kettu", "Failed to download bundle", e)
|
||||
}
|
||||
}
|
||||
|
||||
val patch = object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
runBlocking { httpJob.join() }
|
||||
|
||||
XposedBridge.invokeOriginalMethod(
|
||||
setGlobalVariable,
|
||||
param.thisObject,
|
||||
arrayOf("__PYON_LOADER__", buildLoaderJsonString())
|
||||
)
|
||||
|
||||
preloadsDir
|
||||
.walk()
|
||||
.filter { it.isFile && it.extension == "js" }
|
||||
.forEach { file ->
|
||||
XposedBridge.invokeOriginalMethod(
|
||||
loadScriptFromFile,
|
||||
param.thisObject,
|
||||
arrayOf(file.absolutePath, file.absolutePath, param.args[2])
|
||||
)
|
||||
}
|
||||
|
||||
XposedBridge.invokeOriginalMethod(
|
||||
loadScriptFromFile,
|
||||
param.thisObject,
|
||||
arrayOf(bundle.absolutePath, bundle.absolutePath, param.args[2])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
XposedBridge.hookMethod(loadScriptFromAssets, patch)
|
||||
XposedBridge.hookMethod(loadScriptFromFile, patch)
|
||||
|
||||
// Fighting the side effects of changing the package name
|
||||
if (packageName != "com.discord") {
|
||||
val getIdentifier = Resources::class.java.getDeclaredMethod(
|
||||
"getIdentifier",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
String::class.java
|
||||
)
|
||||
|
||||
XposedBridge.hookMethod(getIdentifier, object: XC_MethodHook() {
|
||||
override fun beforeHookedMethod(mhparam: MethodHookParam) = with(mhparam) {
|
||||
if (args[2] == param.packageName) args[2] = "com.discord"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
package cocobo1.pupu.xposed
|
||||
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
|
||||
abstract class Module {
|
||||
open fun buildJson(builder: JsonObjectBuilder) {}
|
||||
open fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) {}
|
||||
}
|
||||
19
app/src/main/kotlin/io/github/revenge/xposed/Constants.kt
Normal file
19
app/src/main/kotlin/io/github/revenge/xposed/Constants.kt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package cocobo1.pupu.xposed
|
||||
|
||||
class Constants {
|
||||
companion object {
|
||||
const val TARGET_PACKAGE = "com.discord"
|
||||
const val TARGET_ACTIVITY = "$TARGET_PACKAGE.react_activities.ReactActivity"
|
||||
|
||||
const val FILES_DIR = "files/pyoncord"
|
||||
const val CACHE_DIR = "cache/pyoncord"
|
||||
const val MAIN_SCRIPT_FILE = "bundle.js"
|
||||
|
||||
|
||||
const val LOG_TAG = "Kettu"
|
||||
|
||||
const val LOADER_NAME = "KettuXposed"
|
||||
|
||||
const val USER_AGENT = "KettuXposed"
|
||||
}
|
||||
}
|
||||
72
app/src/main/kotlin/io/github/revenge/xposed/Main.kt
Normal file
72
app/src/main/kotlin/io/github/revenge/xposed/Main.kt
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package cocobo1.pupu.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 cocobo1.pupu.xposed.modules.*
|
||||
import cocobo1.pupu.xposed.modules.appearance.FontsModule
|
||||
import cocobo1.pupu.xposed.modules.appearance.SysColorsModule
|
||||
import cocobo1.pupu.xposed.modules.appearance.ThemesModule
|
||||
import cocobo1.pupu.xposed.modules.bridge.AdditionalBridgeMethodsModule
|
||||
import cocobo1.pupu.xposed.modules.bridge.BridgeModule
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
183
app/src/main/kotlin/io/github/revenge/xposed/Module.kt
Normal file
183
app/src/main/kotlin/io/github/revenge/xposed/Module.kt
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package cocobo1.pupu.xposed
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import de.robv.android.xposed.IXposedHookZygoteInit
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
|
||||
data class AppInfo(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
val version: String,
|
||||
val versionCode: Long,
|
||||
)
|
||||
|
||||
abstract class Module {
|
||||
/**
|
||||
* Builds a JSON payload to be injected into the JavaScript context.
|
||||
*/
|
||||
@Deprecated("This will be removed in future versions. Payloads can be replaced via synchronous bridge methods.")
|
||||
open fun buildPayload(builder: JsonObjectBuilder) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called during Zygote initialization.
|
||||
*/
|
||||
open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {}
|
||||
|
||||
/**
|
||||
* Called when a package is loaded.
|
||||
*
|
||||
* Hooks should typically be set up here.
|
||||
*
|
||||
* Bridge methods that do not require [Context] can also be registered here.
|
||||
*/
|
||||
open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {}
|
||||
|
||||
/**
|
||||
* Called after [Context] has been attached to a [android.content.ContextWrapper]
|
||||
* via [android.content.ContextWrapper.attachBaseContext].
|
||||
*
|
||||
* This may be called multiple times for different [Context]s, e.g. when the app is killed by the system.
|
||||
*
|
||||
* For bridge methods that require access to [Context], register them here.
|
||||
*/
|
||||
open fun onContext(context: Context) {}
|
||||
|
||||
/**
|
||||
* Called after an [Activity.onCreate] method is executed.
|
||||
*
|
||||
* UI code that requires an [Activity] context can be placed here.
|
||||
*
|
||||
* Bridge methods must not be registered here, as it can be too late and cause crashes.
|
||||
*
|
||||
* If you need to register bridge methods that require an [Activity], do it in [onContext] and make it async.
|
||||
*
|
||||
* Once an [Activity] is available, execute the queued calls. Then from JS, call the method asynchronously.
|
||||
*/
|
||||
open fun onActivity(activity: Activity) {}
|
||||
|
||||
protected fun Context.getAppInfo(): AppInfo {
|
||||
val pInfo = packageManager.getPackageInfo(packageName, 0)
|
||||
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode
|
||||
else @Suppress("DEPRECATION") pInfo.versionCode.toLong()
|
||||
|
||||
return AppInfo(
|
||||
packageManager.getApplicationLabel(applicationInfo).toString(),
|
||||
packageName,
|
||||
pInfo.versionName ?: versionCode.toString(),
|
||||
versionCode,
|
||||
)
|
||||
}
|
||||
|
||||
protected fun File.asDir() {
|
||||
if (!this.isDirectory) this.delete()
|
||||
this.mkdirs()
|
||||
}
|
||||
|
||||
protected fun File.asFile() {
|
||||
if (!this.isFile) this.deleteRecursively()
|
||||
}
|
||||
|
||||
protected fun Class<*>.method(
|
||||
name: String, vararg params: Class<*>?
|
||||
): Method = getDeclaredMethod(name, *params).apply {
|
||||
isAccessible = true
|
||||
}
|
||||
|
||||
protected fun ClassLoader.safeLoadClass(name: String): Class<*>? = runCatching { loadClass(name) }.getOrNull()
|
||||
|
||||
protected fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook = XposedBridge.hookMethod(this, hook)
|
||||
|
||||
protected fun Method.hook(block: MethodHookBuilder.() -> Unit): XC_MethodHook.Unhook =
|
||||
hook(MethodHookBuilder().apply(block).build())
|
||||
|
||||
protected fun Class<*>.hookMethod(
|
||||
name: String, vararg params: Class<*>?, block: MethodHookBuilder.() -> Unit
|
||||
): XC_MethodHook.Unhook = method(name, *params).hook(MethodHookBuilder().apply(block).build())
|
||||
|
||||
protected class MethodHookBuilder {
|
||||
private var beforeBlock: (HookScope.() -> Unit)? = null
|
||||
private var afterBlock: (HookScope.() -> Unit)? = null
|
||||
|
||||
fun before(block: HookScope.() -> Unit) {
|
||||
beforeBlock = block
|
||||
}
|
||||
|
||||
fun after(block: HookScope.() -> Unit) {
|
||||
afterBlock = block
|
||||
}
|
||||
|
||||
fun build(): XC_MethodHook = object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
val b = beforeBlock
|
||||
if (b != null) {
|
||||
val scope = HookScope(
|
||||
param = param, proceed = { p ->
|
||||
super.beforeHookedMethod(p)
|
||||
})
|
||||
scope.b()
|
||||
} else {
|
||||
super.beforeHookedMethod(param)
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
val a = afterBlock
|
||||
if (a != null) {
|
||||
val scope = HookScope(
|
||||
param = param, proceed = { p ->
|
||||
super.afterHookedMethod(p)
|
||||
})
|
||||
scope.a()
|
||||
} else {
|
||||
super.afterHookedMethod(param)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope object passed to before/after hook blocks.
|
||||
*
|
||||
* Provides:
|
||||
* - Access to the [param] object
|
||||
* - [proceed] to call the original XC_MethodHook super method
|
||||
* - Accessors for `thisObject`, `args`, `result`, and `throwable`
|
||||
*
|
||||
* @property param The [XC_MethodHook.MethodHookParam] for the current hook.
|
||||
* @property proceed Function that calls the super method.
|
||||
*/
|
||||
protected class HookScope internal constructor(
|
||||
val param: XC_MethodHook.MethodHookParam, private val proceed: (XC_MethodHook.MethodHookParam) -> Unit
|
||||
) {
|
||||
/**
|
||||
* Continues with the default XC_MethodHook super behavior.
|
||||
* Equivalent to calling `super.beforeHookedMethod(param)` or
|
||||
* `super.afterHookedMethod(param)` depending on the phase.
|
||||
*/
|
||||
fun proceed() = proceed(param)
|
||||
|
||||
val thisObject: Any? get() = param.thisObject
|
||||
|
||||
val args: Array<Any?> get() = param.args
|
||||
|
||||
var result: Any?
|
||||
get() = param.result
|
||||
set(value) {
|
||||
param.result = value
|
||||
}
|
||||
|
||||
var throwable: Throwable?
|
||||
get() = param.throwable
|
||||
set(value) {
|
||||
param.throwable = value
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/src/main/kotlin/io/github/revenge/xposed/Utils.kt
Normal file
28
app/src/main/kotlin/io/github/revenge/xposed/Utils.kt
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package cocobo1.pupu.xposed
|
||||
|
||||
import android.app.AndroidAppHelper
|
||||
import android.content.Intent
|
||||
import kotlinx.serialization.json.Json
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,23 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.content.res.Resources
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants.Companion.TARGET_PACKAGE
|
||||
import cocobo1.pupu.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.content.res.AssetManager
|
||||
import android.content.res.XModuleResources
|
||||
import de.robv.android.xposed.IXposedHookZygoteInit
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import cocobo1.pupu.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
|
||||
/**
|
||||
* Hooks React Native's script loading methods to load custom scripts and bundles.
|
||||
*
|
||||
* Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files directory.
|
||||
*
|
||||
* The main bundle should be placed in the [Constants.CACHE_DIR] directory named [Constants.MAIN_SCRIPT_FILE].
|
||||
* If the bundle file does not exist, it will attempt to load `assets://revenge.bundle` from the module's assets.
|
||||
*/
|
||||
class HookScriptLoaderModule : Module() {
|
||||
private lateinit var preloadsDir: File
|
||||
private lateinit var mainScript: File
|
||||
|
||||
private lateinit var modulePath: String
|
||||
private lateinit var resources: XModuleResources
|
||||
|
||||
companion object {
|
||||
const val PRELOADS_DIR = "preloads"
|
||||
}
|
||||
|
||||
override fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {
|
||||
this@HookScriptLoaderModule.modulePath = startupParam.modulePath
|
||||
}
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() }
|
||||
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() }
|
||||
|
||||
preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() }
|
||||
mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() }
|
||||
|
||||
listOf(
|
||||
"com.facebook.react.runtime.ReactInstance$1",
|
||||
// TODO: Remove once Discord fully switches to Bridgeless
|
||||
"com.facebook.react.bridge.CatalystInstanceImpl"
|
||||
).mapNotNull { classLoader.safeLoadClass(it) }
|
||||
.forEach { hook(it) }
|
||||
}
|
||||
|
||||
private fun hook(instance: Class<*>) {
|
||||
val loadScriptFromAssets = instance.method(
|
||||
"loadScriptFromAssets",
|
||||
AssetManager::class.java,
|
||||
String::class.java,
|
||||
Boolean::class.javaPrimitiveType
|
||||
)
|
||||
|
||||
val loadScriptFromFile = instance.method(
|
||||
"loadScriptFromFile",
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
Boolean::class.javaPrimitiveType
|
||||
)
|
||||
|
||||
loadScriptFromAssets.hook {
|
||||
before {
|
||||
Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})")
|
||||
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
|
||||
}
|
||||
}
|
||||
|
||||
loadScriptFromFile.hook {
|
||||
before {
|
||||
Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})")
|
||||
runCustomScripts(loadScriptFromFile, loadScriptFromAssets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) {
|
||||
Log.i("Running custom scripts...")
|
||||
|
||||
// TODO: Is there a better way to do this?
|
||||
runBlocking { UpdaterModule.job?.join() }
|
||||
|
||||
val loadSynchronously = args[2]
|
||||
val runScriptFile = { file: File ->
|
||||
Log.i("Loading script: ${file.absolutePath}")
|
||||
|
||||
XposedBridge.invokeOriginalMethod(
|
||||
loadScriptFromFile,
|
||||
thisObject,
|
||||
arrayOf(file.absolutePath, file.absolutePath, loadSynchronously)
|
||||
)
|
||||
|
||||
Unit
|
||||
}
|
||||
|
||||
try {
|
||||
preloadsDir.walk()
|
||||
.filter { it.isFile }
|
||||
.forEach(runScriptFile)
|
||||
|
||||
if (mainScript.exists()) runScriptFile(mainScript)
|
||||
else {
|
||||
Log.i("Main script does not exist, falling back")
|
||||
|
||||
if (!::resources.isInitialized) resources =
|
||||
XModuleResources.createInstance(modulePath, null)
|
||||
|
||||
XposedBridge.invokeOriginalMethod(
|
||||
loadScriptFromAssets,
|
||||
thisObject,
|
||||
arrayOf(resources.assets, "assets://bundle.js", loadSynchronously)
|
||||
)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e("Unable to run scripts:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Companion.reloadApp
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import java.io.File
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class LogBoxModule : Module() {
|
||||
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
|
||||
lateinit var bridgeDevSupportManagerClass: Class<*>
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
this@LogBoxModule.packageParam = packageParam
|
||||
|
||||
try {
|
||||
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
|
||||
bridgeDevSupportManagerClass = classLoader.loadClass("com.facebook.react.devsupport.BridgeDevSupportManager")
|
||||
|
||||
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
|
||||
val handleReloadJSMethod = bridgeDevSupportManagerClass.methods.first { it.name == "handleReloadJS" }
|
||||
val showDevOptionsDialogMethod = bridgeDevSupportManagerClass.methods.first { it.name == "showDevOptionsDialog" }
|
||||
val mReactInstanceDevHelperField = XposedHelpers.findField(bridgeDevSupportManagerClass, "mReactInstanceDevHelper")
|
||||
|
||||
// This enables the LogBox and opens dev option on shake
|
||||
XposedBridge.hookMethod(getUseDeveloperSupportMethod, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
param.result = true
|
||||
}
|
||||
})
|
||||
|
||||
// Replace the method to direct relaunch the app instead of sending reload command to developer server
|
||||
XposedBridge.hookMethod(handleReloadJSMethod, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
reloadApp()
|
||||
param.result = null
|
||||
}
|
||||
})
|
||||
|
||||
// Triggered on shake - Moved from onContext to onLoad
|
||||
XposedBridge.hookMethod(showDevOptionsDialogMethod, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
try {
|
||||
val mReactInstanceDevHelper = mReactInstanceDevHelperField.get(param.thisObject)
|
||||
val getCurrentActivityMethod = mReactInstanceDevHelper.javaClass.methods.first { it.name == "getCurrentActivity" }
|
||||
val context = getCurrentActivityMethod.invoke(mReactInstanceDevHelper) as Context
|
||||
|
||||
showRecoveryAlert(context)
|
||||
} catch (ex: Exception) {
|
||||
Log.e("Failed to show dev options dialog: $ex")
|
||||
}
|
||||
|
||||
// Ignore the original dev menu
|
||||
param.result = null
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e("Error in LogBoxModule.onLoad: $e")
|
||||
}
|
||||
|
||||
return@with
|
||||
}
|
||||
|
||||
private fun showRecoveryAlert(context: Context) {
|
||||
AlertDialog.Builder(context).setTitle("Kettu 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package cocobo1.pupu.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 cocobo1.pupu.xposed.BuildConfig
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Companion.JSON
|
||||
import cocobo1.pupu.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,163 @@
|
|||
package cocobo1.pupu.xposed.modules
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.util.AtomicFile
|
||||
import android.widget.Toast
|
||||
import androidx.core.util.writeBytes
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Companion.JSON
|
||||
import cocobo1.pupu.xposed.Utils.Companion.reloadApp
|
||||
import cocobo1.pupu.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
|
||||
|
||||
@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 = 2000L
|
||||
private const val TIMEOUT = 80000L
|
||||
private const val ETAG_FILE = "etag.txt"
|
||||
private const val CONFIG_FILE = "loader.json"
|
||||
|
||||
private const val DEFAULT_BUNDLE_URL =
|
||||
"https://codeberg.org/cocobo1/Kettu/raw/branch/dist/kettu.min.js"
|
||||
}
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() }
|
||||
val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
|
||||
|
||||
bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE)
|
||||
etag = File(cacheDir, ETAG_FILE)
|
||||
|
||||
val configFile = File(filesDir, CONFIG_FILE)
|
||||
|
||||
config = runCatching {
|
||||
if (configFile.exists()) {
|
||||
JSON.decodeFromString<LoaderConfig>(configFile.readText())
|
||||
} else LoaderConfig()
|
||||
}.getOrDefault(LoaderConfig())
|
||||
|
||||
downloadScript()
|
||||
}
|
||||
|
||||
private fun downloadScript(activity: Activity? = null) {
|
||||
job = scope.launch {
|
||||
try {
|
||||
HttpClient(CIO) {
|
||||
expectSuccess = false
|
||||
install(UserAgent) { agent = Constants.USER_AGENT }
|
||||
install(HttpRedirect) {}
|
||||
}.use { client ->
|
||||
val url = config.customLoadUrl.takeIf { it.enabled }?.url ?: DEFAULT_BUNDLE_URL
|
||||
Log.i("Fetching JS bundle from: $url")
|
||||
|
||||
val response: HttpResponse = client.get(url) {
|
||||
headers {
|
||||
if (etag.exists() && bundle.exists()) {
|
||||
append(HttpHeaders.IfNoneMatch, etag.readText())
|
||||
}
|
||||
}
|
||||
|
||||
// Retries don't need timeout
|
||||
if (activity != null) {
|
||||
timeout {
|
||||
requestTimeoutMillis = if (!bundle.exists()) TIMEOUT else TIMEOUT_CACHED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> {
|
||||
val bytes: ByteArray = response.body()
|
||||
AtomicFile(bundle).writeBytes(bytes)
|
||||
|
||||
val newTag = response.headers[HttpHeaders.ETag]
|
||||
if (!newTag.isNullOrEmpty()) etag.writeText(newTag) else etag.delete()
|
||||
|
||||
Log.i("Bundle updated (${bytes.size} bytes)")
|
||||
|
||||
// This is a retry, so we show a dialog
|
||||
if (activity != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
AlertDialog.Builder(activity).setTitle("Kettu 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)
|
||||
error = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivity(activity: Activity) {
|
||||
error ?: return
|
||||
|
||||
AlertDialog.Builder(activity).setTitle("Kettu Update Failed").setMessage(
|
||||
"""
|
||||
Unable to download the latest version of Kettu.
|
||||
This is usually caused by bad network connection.
|
||||
|
||||
Error: ${error?.message ?: error.toString()}
|
||||
""".trimIndent()
|
||||
).setNegativeButton("Dismiss") { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}.setPositiveButton("Retry Update") { dialog, _ ->
|
||||
error = null
|
||||
downloadScript(activity)
|
||||
Toast.makeText(activity, "Retrying download in background...", Toast.LENGTH_SHORT).show()
|
||||
dialog.dismiss()
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,31 @@
|
|||
// credits to janisslsm from his PR: https://github.com/vendetta-mod/VendettaXposed/pull/17
|
||||
// hooks are modified function from RN codebase
|
||||
|
||||
package cocobo1.pupu.xposed
|
||||
package cocobo1.pupu.xposed.modules.appearance
|
||||
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Build
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.Typeface.CustomFallbackBuilder
|
||||
import android.graphics.fonts.Font
|
||||
import android.graphics.fonts.FontFamily
|
||||
import android.util.Log
|
||||
import 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 kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.IOException
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Companion.JSON
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.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(
|
||||
|
|
@ -35,24 +35,26 @@ data class FontDefinition(
|
|||
val main: Map<String, String>,
|
||||
)
|
||||
|
||||
class FontsModule: Module() {
|
||||
private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic")
|
||||
private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf")
|
||||
private val FONTS_ASSET_PATH = "fonts/"
|
||||
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 buildJson(builder: JsonObjectBuilder) {
|
||||
override fun buildPayload(builder: JsonObjectBuilder) {
|
||||
builder.apply {
|
||||
put("fontPatch", 2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookClass(classLoader: ClassLoader, className: String) {
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
className,
|
||||
"com.facebook.react.views.text.ReactFontManager\$Companion",
|
||||
classLoader,
|
||||
"createAssetTypeface",
|
||||
String::class.java,
|
||||
|
|
@ -66,30 +68,18 @@ class FontsModule: Module() {
|
|||
return createAssetTypeface(fontFamilyName, style, assetManager)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) {
|
||||
try {
|
||||
// Try to hook on versions (280201+)
|
||||
hookClass(classLoader, "com.facebook.react.views.text.ReactFontManager\$Companion")
|
||||
} catch (e: Throwable) {
|
||||
when (e) {
|
||||
// Hook old class (280200-)
|
||||
is NoClassDefFoundError, is XposedHelpers.ClassNotFoundError -> hookClass(classLoader, "com.facebook.react.views.text.ReactFontManager")
|
||||
else -> throw e
|
||||
}
|
||||
}
|
||||
|
||||
val fontDefFile = File(appInfo.dataDir, "files/pyoncord/fonts.json")
|
||||
val fontDefFile = File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply { asFile() }
|
||||
if (!fontDefFile.exists()) return@with
|
||||
|
||||
val fontDef = try {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
json.decodeFromString<FontDefinition>(fontDefFile.readText())
|
||||
} catch (_: Throwable) { return@with }
|
||||
JSON.decodeFromString<FontDefinition>(fontDefFile.readText())
|
||||
} catch (_: Throwable) {
|
||||
return@with
|
||||
}
|
||||
|
||||
fontsDownloadsDir = File(appInfo.dataDir, "files/pyoncord/downloads/fonts").apply { mkdirs() }
|
||||
fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { mkdirs() }
|
||||
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 ->
|
||||
|
|
@ -97,7 +87,7 @@ class FontsModule: Module() {
|
|||
if (!fileName.startsWith(".")) {
|
||||
val fontName = fileName.split('.')[0]
|
||||
if (fontDef.main.keys.none { it == fontName }) {
|
||||
Log.i("Kettu", "Deleting font file: $fileName")
|
||||
Log.i("Deleting font file: $fileName")
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
|
@ -109,35 +99,29 @@ class FontsModule: Module() {
|
|||
async {
|
||||
val url = fontDef.main.getValue(name)
|
||||
try {
|
||||
Log.i("Kettu", "Downloading $name from $url")
|
||||
val file = File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}")
|
||||
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 = "KettuXposed" }
|
||||
install(UserAgent) { agent = Constants.USER_AGENT }
|
||||
}
|
||||
|
||||
val response: HttpResponse = client.get(url)
|
||||
|
||||
if (response.status == HttpStatusCode.OK) {
|
||||
file.writeBytes(response.body())
|
||||
}
|
||||
if (response.status == HttpStatusCode.OK) file.writeBytes(response.body())
|
||||
|
||||
return@async
|
||||
} catch (e: Throwable) {
|
||||
Log.e("Kettu", "Failed to download fonts ($name from $url)", e)
|
||||
Log.e("Failed to download fonts ($name from $url)", e)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
return@with
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAssetTypefaceWithFallbacks(
|
||||
fontFamilyNames: Array<String>,
|
||||
style: Int,
|
||||
assetManager: AssetManager
|
||||
fontFamilyNames: Array<String>, style: Int, assetManager: AssetManager
|
||||
): Typeface? {
|
||||
val fontFamilies: MutableList<FontFamily> = ArrayList()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
|
|
@ -147,31 +131,29 @@ class FontsModule: Module() {
|
|||
try {
|
||||
for (fileExtension in FILE_EXTENSIONS) {
|
||||
val (customName, refName) = fontFamilyName.split(":")
|
||||
val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension")
|
||||
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 (e: Throwable) {
|
||||
// ignore
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
|
||||
for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) {
|
||||
for (fileExtension in FILE_EXTENSIONS) {
|
||||
val fileName = java.lang.StringBuilder()
|
||||
.append(fontRootPath)
|
||||
.append(fontFamilyName)
|
||||
.append(fileExtension)
|
||||
.toString()
|
||||
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 builder = if (fileName[0] == '/') Font.Builder(File(fileName)) else Font.Builder(
|
||||
assetManager, fileName
|
||||
)
|
||||
val font = builder.build()
|
||||
val family = FontFamily.Builder(font).build()
|
||||
fontFamilies.add(family)
|
||||
} catch (e: java.lang.RuntimeException) {
|
||||
} catch (_: RuntimeException) {
|
||||
// If the typeface asset does not exist, try another extension.
|
||||
continue
|
||||
} catch (e: IOException) {
|
||||
} catch (_: IOException) {
|
||||
// If the font asset does not exist, try another extension.
|
||||
continue
|
||||
}
|
||||
|
|
@ -180,9 +162,8 @@ class FontsModule: Module() {
|
|||
}
|
||||
|
||||
// If there's some problem constructing fonts, fall back to the default behavior.
|
||||
if (fontFamilies.size == 0) {
|
||||
return createAssetTypeface(fontFamilyNames[0], style, assetManager)
|
||||
}
|
||||
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])
|
||||
|
|
@ -193,15 +174,12 @@ class FontsModule: Module() {
|
|||
}
|
||||
|
||||
private fun createAssetTypeface(
|
||||
fontFamilyName_: String, style: Int, assetManager: AssetManager
|
||||
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()
|
||||
var fontFamilyName: String = fontFamilyName
|
||||
val fontFamilyNames = fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
for (i in fontFamilyNames.indices) {
|
||||
fontFamilyNames[i] = fontFamilyNames[i].trim()
|
||||
}
|
||||
|
|
@ -222,31 +200,25 @@ class FontsModule: Module() {
|
|||
try {
|
||||
for (fileExtension in FILE_EXTENSIONS) {
|
||||
val (customName, refName) = fontFamilyName.split(":")
|
||||
val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension")
|
||||
val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { asFile() }
|
||||
if (!file.exists()) throw Exception()
|
||||
return Typeface.createFromFile(file.absolutePath)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// ignore
|
||||
} 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 = java.lang.StringBuilder()
|
||||
.append(fontRootPath)
|
||||
.append(fontFamilyName)
|
||||
.append(extension)
|
||||
.append(fileExtension)
|
||||
.toString()
|
||||
|
||||
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 (e: java.lang.RuntimeException) {
|
||||
if (fileName[0] == '/') Typeface.createFromFile(fileName)
|
||||
else Typeface.createFromAsset(assetManager, fileName)
|
||||
} catch (_: RuntimeException) {
|
||||
// If the typeface asset does not exist, try another extension.
|
||||
continue
|
||||
}
|
||||
|
|
@ -1,26 +1,18 @@
|
|||
package cocobo1.pupu.xposed
|
||||
package cocobo1.pupu.xposed.modules.appearance
|
||||
|
||||
import android.R.color
|
||||
import android.app.AndroidAppHelper
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.serialization.Serializable
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
@Serializable
|
||||
data class SysColors(
|
||||
val neutral1: List<String>,
|
||||
val neutral2: List<String>,
|
||||
val accent1: List<String>,
|
||||
val accent2: List<String>,
|
||||
val accent3: List<String>
|
||||
)
|
||||
|
||||
class SysColorsModule : Module() {
|
||||
private lateinit var context: Context
|
||||
private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
override fun buildJson(builder: JsonObjectBuilder) {
|
||||
override fun buildPayload(builder: JsonObjectBuilder) {
|
||||
context = AndroidAppHelper.currentApplication()
|
||||
val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2")
|
||||
val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000)
|
||||
|
|
@ -33,7 +25,7 @@ class SysColorsModule : Module() {
|
|||
val colorName = "system_" + accent + "_" + shade
|
||||
|
||||
val colorResourceId = runCatching {
|
||||
android.R.color::class.java.getField(colorName).getInt(null)
|
||||
color::class.java.getField(colorName).getInt(null)
|
||||
}.getOrElse { 0 }
|
||||
|
||||
add(convertToColor(colorResourceId))
|
||||
|
|
@ -1,24 +1,24 @@
|
|||
package cocobo1.pupu.xposed
|
||||
package cocobo1.pupu.xposed.modules.appearance
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.content.res.Resources
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedBridge
|
||||
import androidx.core.graphics.toColorInt
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Companion.JSON
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.File
|
||||
import kotlinx.serialization.json.*
|
||||
|
||||
@Serializable
|
||||
data class Author(
|
||||
val name: String,
|
||||
val id: String? = null
|
||||
val name: String, val id: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ThemeData(
|
||||
val name: String,
|
||||
|
|
@ -28,31 +28,35 @@ data class ThemeData(
|
|||
val semanticColors: Map<String, List<String>>? = null,
|
||||
val rawColors: Map<String, String>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Theme(
|
||||
val id: String,
|
||||
val selected: Boolean,
|
||||
val data: ThemeData
|
||||
val id: String, val selected: Boolean, val data: ThemeData
|
||||
)
|
||||
|
||||
class ThemeModule : Module() {
|
||||
class ThemesModule : Module() {
|
||||
private lateinit var param: XC_LoadPackage.LoadPackageParam
|
||||
|
||||
private var theme: Theme? = null
|
||||
private val rawColorMap = mutableMapOf<String, Int>()
|
||||
|
||||
private companion object {
|
||||
const val THEME_FILE = "current-theme.json"
|
||||
}
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
override fun buildJson(builder: JsonObjectBuilder) {
|
||||
override fun buildPayload(builder: JsonObjectBuilder) {
|
||||
builder.apply {
|
||||
put("hasThemeSupport", true)
|
||||
if (theme != null)
|
||||
put("storedTheme", Json.encodeToJsonElement<Theme>(theme!!))
|
||||
else
|
||||
put("storedTheme", null)
|
||||
if (theme != null) put("storedTheme", JSON.encodeToJsonElement<Theme>(theme!!))
|
||||
else put("storedTheme", null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) {
|
||||
private fun String.fromScreamingSnakeToCamelCase() =
|
||||
this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } }
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {
|
||||
param = packageParam
|
||||
theme = getTheme()
|
||||
hookTheme()
|
||||
|
|
@ -60,27 +64,21 @@ class ThemeModule : Module() {
|
|||
|
||||
private fun File.isValidish(): Boolean {
|
||||
if (!this.exists()) return false
|
||||
|
||||
|
||||
val text = this.readText()
|
||||
return text.isNotBlank() && text != "{}" && text != "null"
|
||||
}
|
||||
|
||||
private fun getTheme(): Theme? {
|
||||
val filesDir = File(param.appInfo.dataDir, "files").apply { mkdirs() }
|
||||
val pyonDir = File(filesDir, "pyoncord").apply { mkdirs() }
|
||||
val themeFile = File(pyonDir, "current-theme.json")
|
||||
|
||||
val legacyThemeFile = File(filesDir, "vendetta_theme.json")
|
||||
if (legacyThemeFile.isValidish() && !themeFile.isValidish()) {
|
||||
legacyThemeFile.copyTo(themeFile, overwrite = true)
|
||||
}
|
||||
|
||||
val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { asFile() }
|
||||
if (!themeFile.isValidish()) return null
|
||||
|
||||
|
||||
return try {
|
||||
val themeText = themeFile.readText()
|
||||
Json { ignoreUnknownKeys = true }.decodeFromString<Theme>(themeText)
|
||||
} catch (e: Exception) { null }
|
||||
JSON.decodeFromString<Theme>(themeText)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun hookTheme() {
|
||||
|
|
@ -88,18 +86,17 @@ class ThemeModule : Module() {
|
|||
val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme")
|
||||
val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme")
|
||||
|
||||
val theme = this.theme
|
||||
if (theme == null) return
|
||||
val theme = this.theme ?: return
|
||||
|
||||
// Apply rawColors
|
||||
theme.data.rawColors?.forEach { (key, value) ->
|
||||
theme.data.rawColors?.forEach { (key, value) ->
|
||||
rawColorMap[key.lowercase()] = hexStringToColorInt(value)
|
||||
}
|
||||
|
||||
|
||||
// Apply semanticColors
|
||||
theme.data.semanticColors?.forEach { (key, value) ->
|
||||
// TEXT_NORMAL -> getTextNormal
|
||||
val methodName = "get${key.split("_").joinToString("") { it.lowercase().replaceFirstChar { it.uppercase() } }}"
|
||||
val methodName = "get${key.fromScreamingSnakeToCamelCase()}"
|
||||
value.forEachIndexed { index, v ->
|
||||
when (index) {
|
||||
0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v))
|
||||
|
|
@ -118,48 +115,46 @@ class ThemeModule : Module() {
|
|||
)
|
||||
|
||||
val getColorCompatLegacy = themeManager.getDeclaredMethod(
|
||||
"getColorCompat",
|
||||
Context::class.java,
|
||||
Int::class.javaPrimitiveType
|
||||
"getColorCompat", Context::class.java, Int::class.javaPrimitiveType
|
||||
)
|
||||
|
||||
val patch = object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
val arg1 = param.args[0]
|
||||
val patch = MethodHookBuilder().run {
|
||||
before {
|
||||
val arg1 = args[0]
|
||||
val resources = if (arg1 is Context) arg1.resources else (arg1 as Resources)
|
||||
val name = resources.getResourceEntryName(param.args[1] as Int)
|
||||
val name = resources.getResourceEntryName(args[1] as Int)
|
||||
|
||||
if (rawColorMap[name] != null) param.result = rawColorMap[name]
|
||||
if (rawColorMap[name] != null) result = rawColorMap[name]
|
||||
}
|
||||
|
||||
build()
|
||||
}
|
||||
|
||||
XposedBridge.hookMethod(getColorCompat, patch)
|
||||
XposedBridge.hookMethod(getColorCompatLegacy, patch)
|
||||
getColorCompat.hook(patch)
|
||||
getColorCompatLegacy.hook(patch)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB"
|
||||
private fun hexStringToColorInt(hexString: String): Int {
|
||||
return if (hexString.length == 9 ) {
|
||||
return if (hexString.length == 9) {
|
||||
// Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy
|
||||
val alpha = hexString.substring(7, 9)
|
||||
val rrggbb = hexString.substring(1, 7)
|
||||
Color.parseColor("#$alpha$rrggbb")
|
||||
} else Color.parseColor(hexString)
|
||||
"#$alpha$rrggbb".toColorInt()
|
||||
} else hexString.toColorInt()
|
||||
}
|
||||
|
||||
private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) {
|
||||
try {
|
||||
themeClass.getDeclaredMethod(methodName).let { method ->
|
||||
// Log.i("Hooking $methodName -> ${themeValue.toString(16)}")
|
||||
XposedBridge.hookMethod(method, object : XC_MethodHook() {
|
||||
override fun beforeHookedMethod(param: MethodHookParam) {
|
||||
param.result = themeValue
|
||||
method.hook {
|
||||
before {
|
||||
result = themeValue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (ex: NoSuchMethodException) {
|
||||
// do nothing
|
||||
} catch (_: NoSuchMethodException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package cocobo1.pupu.xposed.modules.bridge
|
||||
|
||||
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 cocobo1.pupu.xposed.Module
|
||||
|
||||
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("Kettu Error")
|
||||
.setMessage(
|
||||
"""
|
||||
Kettu: $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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
package cocobo1.pupu.xposed.modules.bridge
|
||||
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage
|
||||
import cocobo1.pupu.xposed.BuildConfig
|
||||
import cocobo1.pupu.xposed.Constants
|
||||
import cocobo1.pupu.xposed.Module
|
||||
import cocobo1.pupu.xposed.Utils.Log
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
|
||||
/**
|
||||
* See for possible return types:
|
||||
* https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19
|
||||
*
|
||||
* You may return a [Unit] and the resulting value will be `null`.
|
||||
*/
|
||||
typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any?
|
||||
|
||||
typealias BridgeMethodArgs = ArrayList<Any>
|
||||
|
||||
/**
|
||||
* A module that exposes a bridge for calling methods from JavaScript.
|
||||
*
|
||||
* To call a method, pass an object with the following structure to a hooked method:
|
||||
* ```js
|
||||
* {
|
||||
* revenge: {
|
||||
* method: "method.name",
|
||||
* args: [arg1, arg2, ...]
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* For methods that accept additional arguments, only the first object argument is used for the bridge call.
|
||||
*
|
||||
* The result will be an object with either a `result` or `error` key:
|
||||
* ```js
|
||||
* {
|
||||
* result: ... // The return value of the method
|
||||
* }
|
||||
* ```
|
||||
* or
|
||||
* ```js
|
||||
* {
|
||||
* error: "Error message"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class BridgeModule : Module() {
|
||||
private lateinit var readableMapGetString: Method
|
||||
private lateinit var readableMapToHashMap: Method
|
||||
private lateinit var argumentsMakeNative: Method
|
||||
|
||||
companion object {
|
||||
private const val CALL_DATA_KEY = "revenge"
|
||||
private const val METHOD_NAME_KEY = "method"
|
||||
private const val METHOD_ARGS_KEY = "args"
|
||||
|
||||
private fun File.openFileGuarded() {
|
||||
if (!this.exists()) throw Error("Path does not exist: $path")
|
||||
if (!this.isFile) throw Error("Path is not a file: $path")
|
||||
}
|
||||
|
||||
private val methods: MutableMap<String, BridgeMethodCallback> = mutableMapOf("revenge.info" to {
|
||||
mapOf(
|
||||
"name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE
|
||||
)
|
||||
}, "revenge.test" to {
|
||||
mapOf(
|
||||
"string" to "string",
|
||||
"number" to 7256,
|
||||
"array" to listOf("testing", 527737, listOf(true)),
|
||||
"object" to mapOf("nested" to true),
|
||||
"boolean" to false,
|
||||
"args" to it,
|
||||
)
|
||||
}, "revenge.fs.delete" to {
|
||||
val (path) = it
|
||||
File(path as String).run {
|
||||
if (this.isDirectory) this.deleteRecursively()
|
||||
else this.delete()
|
||||
}
|
||||
}, "revenge.fs.exists" to {
|
||||
val (path) = it
|
||||
File(path as String).exists()
|
||||
}, "revenge.fs.read" to { it ->
|
||||
val (path) = it
|
||||
val file = File(path as String).apply { openFileGuarded() }
|
||||
|
||||
file.bufferedReader().use { it.readText() }
|
||||
}, "revenge.fs.write" to { it ->
|
||||
val (path, contents) = it
|
||||
val file = File(path as String).apply { openFileGuarded() }
|
||||
|
||||
file.writeText(contents as String)
|
||||
})
|
||||
|
||||
/**
|
||||
* Registers a bridge method that can be called from JavaScript.
|
||||
*
|
||||
* If the method is already registered, it will be overwritten.
|
||||
*
|
||||
* @param name The name of the method to register.
|
||||
* @param callback The callback to invoke when the method is called.
|
||||
*/
|
||||
fun registerMethod(name: String, callback: BridgeMethodCallback) {
|
||||
if (methods.containsKey(name)) Log.w("Bridge method already exists and will be overridden: $name")
|
||||
methods[name] = callback
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
|
||||
val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments")
|
||||
val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap")
|
||||
val promise = classLoader.loadClass("com.facebook.react.bridge.Promise")
|
||||
|
||||
val promiseResolve = promise.method("resolve", Object::class.java)
|
||||
argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java)
|
||||
readableMapGetString = readableMap.method("getString", String::class.java)
|
||||
readableMapToHashMap = readableMap.method("toHashMap")
|
||||
|
||||
classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager")
|
||||
.hookMethod("getBBox", Double::class.javaObjectType, readableMap) {
|
||||
before {
|
||||
callBridgeMethod(readableMapToHashMap(args[1]!!))?.let { result = it.toNativeObject() }
|
||||
}
|
||||
}
|
||||
|
||||
classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule")
|
||||
.hookMethod("readAsDataURL", readableMap, promise) {
|
||||
before {
|
||||
val (readableMap, promise) = args
|
||||
callBridgeMethod(readableMapToHashMap(readableMap!!))?.let {
|
||||
promiseResolve.invoke(promise, it.toNativeObject())
|
||||
result = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return@with
|
||||
}
|
||||
|
||||
private fun Any?.toNativeObject(): Any? = argumentsMakeNative.invoke(
|
||||
null, when (this) {
|
||||
Unit -> null
|
||||
else -> this
|
||||
}
|
||||
)
|
||||
|
||||
private fun readableMapToHashMap(map: Any): HashMap<String, Any?> {
|
||||
@Suppress("UNCHECKED_CAST") return readableMapToHashMap.invoke(map) as HashMap<String, Any?>
|
||||
}
|
||||
|
||||
private fun callBridgeMethod(hashMap: HashMap<String, Any?>): Map<String, Any?>? = try {
|
||||
val (method, args) = getBridgeCallData(hashMap) ?: return null
|
||||
val ret = method(args).toNativeObject()
|
||||
mapOf("result" to ret)
|
||||
} catch (e: Throwable) {
|
||||
mapOf("error" to e.toString())
|
||||
}
|
||||
|
||||
private fun getBridgeCallData(hashMap: HashMap<String, Any?>): Pair<BridgeMethodCallback, BridgeMethodArgs>? {
|
||||
@Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap<String, Any?>?
|
||||
data ?: return null
|
||||
|
||||
@Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String
|
||||
val method = methods[name]
|
||||
method ?: throw Error("Method not registered: $name")
|
||||
|
||||
@Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs
|
||||
|
||||
return Pair(method, args)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ buildscript {
|
|||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.9.2")
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10")
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
|
@ -23,4 +23,4 @@ allprojects {
|
|||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
[versions]
|
||||
agp = "8.9.2"
|
||||
kotlin = "2.1.0"
|
||||
coreKtx = "1.15.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
espressoCore = "3.6.1"
|
||||
appcompat = "1.7.0"
|
||||
material = "1.12.0"
|
||||
xposed = "82"
|
||||
yukiApi = "1.2.1"
|
||||
ksp = "2.1.0-1.0.29"
|
||||
aliuhook = "main-SNAPSHOT"
|
||||
libunbound = "1.0.0"
|
||||
kotlinxSerializationJson = "1.8.0"
|
||||
kotlinxCoroutinesAndroid = "1.10.1"
|
||||
ktorClientCore = "3.1.1"
|
||||
ktorClientCio = "3.1.1"
|
||||
kotlinReflect = "2.1.20-RC"
|
||||
kotlinSerialization = "2.1.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
aliuhook = { group = "com.aliucord", name = "Aliuhook", version.ref = "aliuhook" }
|
||||
xposed = { group = "de.robv.android.xposed", name = "api", version.ref = "xposed" }
|
||||
yuki-api = { group = "com.highcapable.yukihookapi", name = "api", version.ref = "yukiApi" }
|
||||
libunbound = { group = "dev.rushii", name = "libunbound", version.ref = "libunbound" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
|
||||
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktorClientCore" }
|
||||
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktorClientCio" }
|
||||
kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlinReflect" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
kotlin-serialization = { id= "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" }
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue