diff --git a/.gitignore b/.gitignore index 0598b79..b2ed268 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,3 @@ build .externalNativeBuild .cxx local.properties -.idea/AndroidProjectSystem.xml -/app/release -/app/build \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml deleted file mode 100644 index 371f2e2..0000000 --- a/.idea/appInsightsSettings.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 7643783..0def1f7 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,121 +1,10 @@ + diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 7b3006b..639c779 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -13,7 +13,6 @@ - diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index e34606c..0000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index c22b6fa..0000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index b2c751a..e0ef6f7 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,9 +1,8 @@ + - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 35eb1dd..94a25f7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0e14d8e..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "java.configuration.updateBuildConfiguration": "disabled" -} \ No newline at end of file diff --git a/Images/kettu.png b/Images/kettu.png deleted file mode 100644 index c047af7..0000000 Binary files a/Images/kettu.png and /dev/null differ diff --git a/Images/pupu_logo.png b/Images/pupu_logo.png deleted file mode 100644 index babfe18..0000000 Binary files a/Images/pupu_logo.png and /dev/null differ diff --git a/README.md b/README.md deleted file mode 100644 index 0451a8f..0000000 --- a/README.md +++ /dev/null @@ -1,28 +0,0 @@ -
- - Kettu logo - - # KettuXposed - - Easily install Kettu on your rooted device! - - [![Latest release](https://img.shields.io/github/v/release/C0C0B01/KettuXposed?color=3AB8BA&display_name=release&label=Latest&style=for-the-badge)](https://github.com/C0C0B01/KettuXposed/releases/latest) - - --- - -
- - ![Downloads (latest)](https://img.shields.io/github/downloads/C0C0B01/KettuXposed/latest/total?style=for-the-badge&logo=github&label=Downloads%20(Latest)&color=blue) - ![Total downloads](https://img.shields.io/github/downloads/C0C0B01/KettuXposed/total?style=for-the-badge&logo=github&label=Downloads%20(Total)&color=blue) - -
- -
- -## Contributing - -This is an open-source project, you can do so without any programming. - -Here are a few things you can do: - -- [Test and report issues](https://github.com/C0C0B01/KettuXposed/issues/new/choose) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cdebb75..c42da37 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } diff --git a/app/release/app-release.apk b/app/release/app-release.apk new file mode 100644 index 0000000..972c5ff Binary files /dev/null and b/app/release/app-release.apk differ diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..e08a62f Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..fe21fe4 Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..e8e10db --- /dev/null +++ b/app/release/output-metadata.json @@ -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 +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f830c6d..bf5d629 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,25 +1,25 @@ + xmlns:tools="http://schemas.android.com/tools"> + android:name="xposedmodule" + android:value="true"/> + android:name="xposeddescription" + android:value="Xposed module to inject Kettu into discord"/> + android:name="xposedminversion" + android:value="82"/> \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/pupu/xposed/LogBoxModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/LogBoxModule.kt deleted file mode 100644 index e804f7e..0000000 --- a/app/src/main/kotlin/io/github/pupu/xposed/LogBoxModule.kt +++ /dev/null @@ -1,117 +0,0 @@ -package cocobo1.pupu.xposed - -import android.app.AlertDialog -import android.app.AndroidAppHelper -import android.content.Context -import android.content.Intent -import android.util.Log -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.XposedHelpers -import de.robv.android.xposed.callbacks.XC_LoadPackage -import java.io.File -import kotlin.system.exitProcess - -class LogBoxModule: Module() { - lateinit var packageParam: XC_LoadPackage.LoadPackageParam - - override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) { - this@LogBoxModule.packageParam = packageParam - - val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") - val bridgeDevSupportManagerClass = classLoader.loadClass("com.facebook.react.devsupport.BridgeDevSupportManager") - - val mReactInstanceDevHelperField = XposedHelpers.findField(bridgeDevSupportManagerClass, "mReactInstanceDevHelper") - - val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" } - val handleReloadJSMethod = bridgeDevSupportManagerClass.methods.first { it.name == "handleReloadJS" } - val showDevOptionsDialogMethod = bridgeDevSupportManagerClass.methods.first { it.name == "showDevOptionsDialog" } - - var alertDialog: AlertDialog? = null - - // This enables the LogBox and opens dev option on shake - XposedBridge.hookMethod( - getUseDeveloperSupportMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - param.result = true - } - } - ) - - // Triggered on shake - XposedBridge.hookMethod( - showDevOptionsDialogMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - try { - if (alertDialog == null) { - val mReactInstanceDevHelper = - mReactInstanceDevHelperField.get(param.thisObject) - val getCurrentActivityMethod = - mReactInstanceDevHelper.javaClass.methods.first { it.name == "getCurrentActivity" } - - val context = - getCurrentActivityMethod.invoke(mReactInstanceDevHelper) as Context - - alertDialog = showRecoveryAlert(context) { - alertDialog = null - } - } - } catch (ex: Exception) { - Log.e("Kettu", "Failed to show dev options dialog: $ex") - alertDialog = null - } - - // Ignore the original dev menu - param.result = null - } - } - ) - - // Replace the method to direct relaunch the app instead of sending reload command to developer server - XposedBridge.hookMethod( - handleReloadJSMethod, - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - reloadApp() - } - } - ) - - return@with - } - - private fun showRecoveryAlert(context: Context, onClose: () -> Unit): AlertDialog { - return AlertDialog.Builder(context) - .setTitle("Kettu Recovery Options") - .setItems(arrayOf("Reload", "Delete bundle.js")) { _, which -> - when (which) { - 0 -> { - reloadApp() - } - 1 -> { - val bundleFile = File(packageParam.appInfo.dataDir, "cache/pyoncord/bundle.js") - if (bundleFile.exists()) { - bundleFile.delete() - } - - reloadApp() - } - } - - onClose() - } - .setOnDismissListener { - onClose() - } - .show() - } - - private fun reloadApp() { - val application = AndroidAppHelper.currentApplication() - val intent = application.packageManager.getLaunchIntentForPackage(application.packageName) - application.startActivity(Intent.makeRestartActivityTask(intent!!.component)) - exitProcess(0) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/pupu/xposed/Main.kt b/app/src/main/kotlin/io/github/pupu/xposed/Main.kt deleted file mode 100644 index 769f8e7..0000000 --- a/app/src/main/kotlin/io/github/pupu/xposed/Main.kt +++ /dev/null @@ -1,230 +0,0 @@ -package cocobo1.pupu.xposed - -import android.app.Activity -import android.content.res.AssetManager -import android.content.res.Resources -import android.util.Log -import android.os.Bundle -import android.widget.Toast -import de.robv.android.xposed.IXposedHookLoadPackage -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.callbacks.XC_LoadPackage -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.client.engine.cio.* -import io.ktor.client.statement.* -import io.ktor.client.plugins.* -import io.ktor.http.* -import kotlinx.coroutines.* -import kotlinx.serialization.* -import kotlinx.serialization.json.* -import java.io.File - -@Serializable -data class CustomLoadUrl( - val enabled: Boolean, - val url: String -) - -@Serializable -data class LoaderConfig( - val customLoadUrl: CustomLoadUrl -) - -class Main : IXposedHookLoadPackage { - private val Modules: Array = arrayOf( - ThemeModule(), - SysColorsModule(), - FontsModule(), - LogBoxModule() - ) - - fun buildLoaderJsonString(): String { - val obj = buildJsonObject { - put("loaderName", "KettuXposed") - put("loaderVersion", BuildConfig.VERSION_NAME) - - for (module in Modules) { - module.buildJson(this) - } - } - - return Json.encodeToString(obj) - } - - override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) { - val reactActivity = runCatching { - lpparam.classLoader.loadClass("com.discord.react_activities.ReactActivity") - }.getOrElse { return } // Package is not our the target app, return - - var activity: Activity? = null - val onActivityCreateCallback = mutableSetOf<(activity: Activity) -> Unit>() - - XposedBridge.hookMethod(reactActivity.getDeclaredMethod("onCreate", Bundle::class.java), object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - activity = param.thisObject as Activity - onActivityCreateCallback.forEach { cb -> cb(activity!!) } - onActivityCreateCallback.clear() - } - }) - - init(lpparam) { cb -> - if (activity != null) cb(activity!!) - else onActivityCreateCallback.add(cb) - } - } - - private fun init( - param: XC_LoadPackage.LoadPackageParam, - onActivityCreate: ((activity: Activity) -> Unit) -> Unit - ) = with (param) { - val catalystInstanceImpl = classLoader.loadClass("com.facebook.react.bridge.CatalystInstanceImpl") - - for (module in Modules) module.onInit(param) - - val loadScriptFromAssets = catalystInstanceImpl.getDeclaredMethod( - "loadScriptFromAssets", - AssetManager::class.java, - String::class.java, - Boolean::class.javaPrimitiveType - ).apply { isAccessible = true } - - val loadScriptFromFile = catalystInstanceImpl.getDeclaredMethod( - "loadScriptFromFile", - String::class.java, - String::class.java, - Boolean::class.javaPrimitiveType - ).apply { isAccessible = true } - - val setGlobalVariable = catalystInstanceImpl.getDeclaredMethod( - "setGlobalVariable", - String::class.java, - String::class.java - ).apply { isAccessible = true } - - val cacheDir = File(appInfo.dataDir, "cache/pyoncord").apply { mkdirs() } - val filesDir = File(appInfo.dataDir, "files/pyoncord").apply { mkdirs() } - - val preloadsDir = File(filesDir, "preloads").apply { mkdirs() } - val bundle = File(cacheDir, "bundle.js") - val etag = File(cacheDir, "etag.txt") - - val configFile = File(filesDir, "loader.json") - - val config = try { - if (!configFile.exists()) throw Exception() - val json = Json { ignoreUnknownKeys = true } - json.decodeFromString(configFile.readText()) - } catch (_: Exception) { - LoaderConfig( - customLoadUrl = CustomLoadUrl( - enabled = false, - url = "" // Not used - ) - ) - } - - val scope = MainScope() - val httpJob = scope.async(Dispatchers.IO) { - try { - val client = HttpClient(CIO) { - expectSuccess = true - install(HttpTimeout) { - requestTimeoutMillis = if (bundle.exists()) 4000 else 20000 - } - install(UserAgent) { agent = "KettuXposed" } - } - - val url = - if (config.customLoadUrl.enabled) config.customLoadUrl.url - else "https://raw.githubusercontent.com/C0C0B01/Kettu/refs/heads/dist/kettu.min.js" - - Log.e("Kettu", "Fetching JS bundle from $url") - - val response: HttpResponse = client.get(url) { - headers { - if (etag.exists() && bundle.exists()) { - append(HttpHeaders.IfNoneMatch, etag.readText()) - } - } - } - - bundle.writeBytes(response.body()) - if (response.headers["Etag"] != null) { - etag.writeText(response.headers["Etag"]!!) - } - else if (etag.exists()) { - // This is called when server does not return an E-tag, so clear em - etag.delete() - } - - return@async - } catch (e: RedirectResponseException) { - if (e.response.status != HttpStatusCode.NotModified) throw e - Log.e("Kettu", "Server responded with status code 304 - no changes to file") - } catch (e: Throwable) { - onActivityCreate { activity -> - activity.runOnUiThread { - Toast.makeText( - activity.applicationContext, - "Failed to fetch JS bundle, Kettu may not load!", - Toast.LENGTH_SHORT - ).show() - } - } - - Log.e("Kettu", "Failed to download bundle", e) - } - } - - val patch = object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - runBlocking { httpJob.join() } - - XposedBridge.invokeOriginalMethod( - setGlobalVariable, - param.thisObject, - arrayOf("__PYON_LOADER__", buildLoaderJsonString()) - ) - - preloadsDir - .walk() - .filter { it.isFile && it.extension == "js" } - .forEach { file -> - XposedBridge.invokeOriginalMethod( - loadScriptFromFile, - param.thisObject, - arrayOf(file.absolutePath, file.absolutePath, param.args[2]) - ) - } - - XposedBridge.invokeOriginalMethod( - loadScriptFromFile, - param.thisObject, - arrayOf(bundle.absolutePath, bundle.absolutePath, param.args[2]) - ) - } - } - - XposedBridge.hookMethod(loadScriptFromAssets, patch) - XposedBridge.hookMethod(loadScriptFromFile, patch) - - // Fighting the side effects of changing the package name - if (packageName != "com.discord") { - val getIdentifier = Resources::class.java.getDeclaredMethod( - "getIdentifier", - String::class.java, - String::class.java, - String::class.java - ) - - XposedBridge.hookMethod(getIdentifier, object: XC_MethodHook() { - override fun beforeHookedMethod(mhparam: MethodHookParam) = with(mhparam) { - if (args[2] == param.packageName) args[2] = "com.discord" - } - }) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/pupu/xposed/PupuModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/PupuModule.kt deleted file mode 100644 index 139b7ea..0000000 --- a/app/src/main/kotlin/io/github/pupu/xposed/PupuModule.kt +++ /dev/null @@ -1,9 +0,0 @@ -package cocobo1.pupu.xposed - -import de.robv.android.xposed.callbacks.XC_LoadPackage -import kotlinx.serialization.json.JsonObjectBuilder - -abstract class Module { - open fun buildJson(builder: JsonObjectBuilder) {} - open fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) {} -} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt b/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt new file mode 100644 index 0000000..3e49ccb --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Main.kt b/app/src/main/kotlin/io/github/revenge/xposed/Main.kt new file mode 100644 index 0000000..a93c45d --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/Main.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Module.kt b/app/src/main/kotlin/io/github/revenge/xposed/Module.kt new file mode 100644 index 0000000..8b022af --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/Module.kt @@ -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 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt b/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt new file mode 100644 index 0000000..b7eb318 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt new file mode 100644 index 0000000..09e12d8 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt @@ -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 + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt new file mode 100644 index 0000000..3acc849 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt new file mode 100644 index 0000000..6736940 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt @@ -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() + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt new file mode 100644 index 0000000..48f3cab --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt @@ -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() { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt new file mode 100644 index 0000000..e2c5307 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt @@ -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(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() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt similarity index 64% rename from app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt rename to app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt index 0bd107e..9df7124 100644 --- a/app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt @@ -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, ) -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(fontDefFile.readText()) - } catch (_: Throwable) { return@with } + JSON.decodeFromString(fontDefFile.readText()) + } catch (_: Throwable) { + return@with + } - fontsDownloadsDir = File(appInfo.dataDir, "files/pyoncord/downloads/fonts").apply { mkdirs() } - fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { mkdirs() } + 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, - style: Int, - assetManager: AssetManager + fontFamilyNames: Array, style: Int, assetManager: AssetManager ): Typeface? { val fontFamilies: MutableList = 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 } diff --git a/app/src/main/kotlin/io/github/pupu/xposed/SysColorsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt similarity index 76% rename from app/src/main/kotlin/io/github/pupu/xposed/SysColorsModule.kt rename to app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt index 4f400a8..95c550a 100644 --- a/app/src/main/kotlin/io/github/pupu/xposed/SysColorsModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt @@ -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, - val neutral2: List, - val accent1: List, - val accent2: List, - val accent3: List -) - class SysColorsModule : Module() { private lateinit var context: Context private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - override fun buildJson(builder: JsonObjectBuilder) { + 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)) diff --git a/app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt similarity index 55% rename from app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt rename to app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt index 3caf491..09beca1 100644 --- a/app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt @@ -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>? = null, val rawColors: Map? = 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() + 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!!)) - else - put("storedTheme", null) + if (theme != null) put("storedTheme", JSON.encodeToJsonElement(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(themeText) - } catch (e: Exception) { null } + JSON.decodeFromString(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) { } } } \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt new file mode 100644 index 0000000..66039b1 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt new file mode 100644 index 0000000..3ccb39e --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt @@ -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 + +/** + * A module that exposes a bridge for calling methods from JavaScript. + * + * To call a method, pass an object with the following structure to a hooked method: + * ```js + * { + * revenge: { + * method: "method.name", + * args: [arg1, arg2, ...] + * } + * } + * ``` + * + * For methods that accept additional arguments, only the first object argument is used for the bridge call. + * + * The result will be an object with either a `result` or `error` key: + * ```js + * { + * result: ... // The return value of the method + * } + * ``` + * or + * ```js + * { + * error: "Error message" + * } + * ``` + */ +class BridgeModule : Module() { + private lateinit var readableMapGetString: Method + private lateinit var readableMapToHashMap: Method + private lateinit var argumentsMakeNative: Method + + companion object { + private const val CALL_DATA_KEY = "revenge" + private const val METHOD_NAME_KEY = "method" + private const val METHOD_ARGS_KEY = "args" + + private fun File.openFileGuarded() { + if (!this.exists()) throw Error("Path does not exist: $path") + if (!this.isFile) throw Error("Path is not a file: $path") + } + + private val methods: MutableMap = mutableMapOf("revenge.info" to { + mapOf( + "name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE + ) + }, "revenge.test" to { + mapOf( + "string" to "string", + "number" to 7256, + "array" to listOf("testing", 527737, listOf(true)), + "object" to mapOf("nested" to true), + "boolean" to false, + "args" to it, + ) + }, "revenge.fs.delete" to { + val (path) = it + File(path as String).run { + if (this.isDirectory) this.deleteRecursively() + else this.delete() + } + }, "revenge.fs.exists" to { + val (path) = it + File(path as String).exists() + }, "revenge.fs.read" to { it -> + val (path) = it + val file = File(path as String).apply { openFileGuarded() } + + file.bufferedReader().use { it.readText() } + }, "revenge.fs.write" to { it -> + val (path, contents) = it + val file = File(path as String).apply { openFileGuarded() } + + file.writeText(contents as String) + }) + + /** + * Registers a bridge method that can be called from JavaScript. + * + * If the method is already registered, it will be overwritten. + * + * @param name The name of the method to register. + * @param callback The callback to invoke when the method is called. + */ + fun registerMethod(name: String, callback: BridgeMethodCallback) { + if (methods.containsKey(name)) Log.w("Bridge method already exists and will be overridden: $name") + methods[name] = callback + } + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments") + val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap") + val promise = classLoader.loadClass("com.facebook.react.bridge.Promise") + + val promiseResolve = promise.method("resolve", Object::class.java) + argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java) + readableMapGetString = readableMap.method("getString", String::class.java) + readableMapToHashMap = readableMap.method("toHashMap") + + classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager") + .hookMethod("getBBox", Double::class.javaObjectType, readableMap) { + before { + callBridgeMethod(readableMapToHashMap(args[1]!!))?.let { result = it.toNativeObject() } + } + } + + classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule") + .hookMethod("readAsDataURL", readableMap, promise) { + before { + val (readableMap, promise) = args + callBridgeMethod(readableMapToHashMap(readableMap!!))?.let { + promiseResolve.invoke(promise, it.toNativeObject()) + result = null + } + } + } + + return@with + } + + private fun Any?.toNativeObject(): Any? = argumentsMakeNative.invoke( + null, when (this) { + Unit -> null + else -> this + } + ) + + private fun readableMapToHashMap(map: Any): HashMap { + @Suppress("UNCHECKED_CAST") return readableMapToHashMap.invoke(map) as HashMap + } + + private fun callBridgeMethod(hashMap: HashMap): Map? = try { + val (method, args) = getBridgeCallData(hashMap) ?: return null + val ret = method(args).toNativeObject() + mapOf("result" to ret) + } catch (e: Throwable) { + mapOf("error" to e.toString()) + } + + private fun getBridgeCallData(hashMap: HashMap): Pair? { + @Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap? + data ?: return null + + @Suppress("UNCHECKED_CAST") val name = data[METHOD_NAME_KEY] as String + val method = methods[name] + method ?: throw Error("Method not registered: $name") + + @Suppress("UNCHECKED_CAST") val args = data[METHOD_ARGS_KEY] as BridgeMethodArgs + + return Pair(method, args) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 56b0a88..d421a94 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ buildscript { mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:8.9.2") + classpath("com.android.tools.build:gradle:8.11.0") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.10") // NOTE: Do not place your application dependencies here; they belong @@ -23,4 +23,4 @@ allprojects { tasks.register("clean") { delete(rootProject.layout.buildDirectory) -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml deleted file mode 100644 index cc01b00..0000000 --- a/gradle/libs.versions.toml +++ /dev/null @@ -1,43 +0,0 @@ -[versions] -agp = "8.9.2" -kotlin = "2.1.0" -coreKtx = "1.15.0" -junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" -appcompat = "1.7.0" -material = "1.12.0" -xposed = "82" -yukiApi = "1.2.1" -ksp = "2.1.0-1.0.29" -aliuhook = "main-SNAPSHOT" -libunbound = "1.0.0" -kotlinxSerializationJson = "1.8.0" -kotlinxCoroutinesAndroid = "1.10.1" -ktorClientCore = "3.1.1" -ktorClientCio = "3.1.1" -kotlinReflect = "2.1.20-RC" -kotlinSerialization = "2.1.0" - -[libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } -aliuhook = { group = "com.aliucord", name = "Aliuhook", version.ref = "aliuhook" } -xposed = { group = "de.robv.android.xposed", name = "api", version.ref = "xposed" } -yuki-api = { group = "com.highcapable.yukihookapi", name = "api", version.ref = "yukiApi" } -libunbound = { group = "dev.rushii", name = "libunbound", version.ref = "libunbound" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } -kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } -ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktorClientCore" } -ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktorClientCio" } -kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlinReflect" } - -[plugins] -android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -kotlin-serialization = { id= "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME