diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8fabe0f..b37574a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,52 +2,52 @@ name: Build APK on: [push] jobs: - build: - name: Build and push - runs-on: ubuntu-latest + build: + name: Build and push + runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 + steps: + - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 - with: - distribution: "temurin" - java-version: "17" + - uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" - - name: "Setup Gradle" - uses: gradle/gradle-build-action@v2 + - name: "Setup Gradle" + uses: gradle/gradle-build-action@v2 - - name: "Grant execution permission" - run: chmod +x ./gradlew + - name: "Grant execution permission" + run: chmod +x ./gradlew - - name: "Build APKs" - run: ./gradlew build + - name: "Build APKs" + run: ./gradlew build - - name: "Setup directories" - run: | - mkdir -p /tmp/pupubuild/apks - mkdir -p /tmp/pupubuild/tools + - name: "Setup directories" + run: | + mkdir -p /tmp/ShiggyXposedbuild/apks + mkdir -p /tmp/ShiggyXposedbuild/tools - - name: "Move debug APK" - run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/pupubuild/apks + - name: "Move debug APK" + run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/ShiggyXposedbuild/apks - - name: "Download uber-apk-signer" - run: wget -nv "https://github.com/patrickfav/uber-apk-signer/releases/download/v1.2.1/uber-apk-signer-1.2.1.jar" -O /tmp/pupubuild/tools/uber-apk-signer.jar + - name: "Download uber-apk-signer" + run: wget -nv "https://github.com/patrickfav/uber-apk-signer/releases/download/v1.2.1/uber-apk-signer-1.2.1.jar" -O /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar - - name: "Sign release APK" - run: java -jar /tmp/pupubuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/pupubuild/apks/ + - name: "Sign release APK" + run: java -jar /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/ShiggyXposedbuild/apks/ - - name: "Rename release APK" - run: mv /tmp/pupubuild/apks/app-release-aligned-debugSigned.apk /tmp/pupubuild/apks/app-release.apk + - name: "Rename release APK" + run: mv /tmp/ShiggyXposedbuild/apks/app-release-aligned-debugSigned.apk /tmp/ShiggyXposedbuild/apks/app-release.apk - - name: "Upload debug APK" - uses: actions/upload-artifact@v4 - with: - name: app-debug - path: /tmp/pupubuild/apks/app-debug.apk + - name: "Upload debug APK" + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: /tmp/ShiggyXposedbuild/apks/app-debug.apk - - name: "Upload release APK" - uses: actions/upload-artifact@v4 - with: - name: app-release - path: /tmp/pupubuild/apks/app-release.apk \ No newline at end of file + - name: "Upload release APK" + uses: actions/upload-artifact@v4 + with: + name: app-release + path: /tmp/ShiggyXposedbuild/apks/app-release.apk diff --git a/.gitignore b/.gitignore index 0598b79..162ad5d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,5 @@ build .externalNativeBuild .cxx local.properties -.idea/AndroidProjectSystem.xml -/app/release -/app/build \ No newline at end of file +app/release/release/ +release diff --git a/.idea/.name b/.idea/.name index 29d110d..e551d4f 100644 --- a/.idea/.name +++ b/.idea/.name @@ -1 +1 @@ -KettuXposed \ No newline at end of file +ShiggyXposed 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..2557637 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,9 +1,7 @@ - - - - - \ 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..46af0dd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,15 +5,15 @@ plugins { } android { - namespace = "cocobo1.pupu.xposed" + namespace = "ShiggyXposed.xposed" compileSdk = 36 defaultConfig { - applicationId = "cocobo1.pupu.xposed" + applicationId = "ShiggyXposed.xposed" minSdk = 22 targetSdk = 36 - versionCode = 1130 - versionName = "1.1.3" + versionCode = 1000 + versionName = "1.0.0" } buildTypes { @@ -29,41 +29,30 @@ android { } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_21 - } - kotlinOptions { - jvmTarget = "21" - } - buildFeatures { - buildConfig = true + targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { jvmTarget = "17" } + buildFeatures { buildConfig = true } } -repositories { - maven("https://jitpack.io") +repositories { + mavenCentral() + google() + maven("https://jitpack.io") } dependencies { compileOnly("de.robv.android.xposed:api:82") - compileOnly(libs.xposed) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") - implementation(libs.libunbound) - implementation(libs.kotlinx.serialization.json) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.ktor.client.core) - implementation(libs.ktor.client.cio) - implementation(libs.kotlin.reflect) - - implementation(libs.yuki.api) - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.material) - - testImplementation(libs.junit) - - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) + implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0") + implementation("io.ktor:ktor-client-core:3.3.0") + implementation("io.ktor:ktor-client-cio:3.3.0") + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f830c6d..0c1bdf6 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 ShiggyCord into discord"/> + android:name="xposedminversion" + android:value="82"/> - \ No newline at end of file + diff --git a/app/src/main/assets/xposed_init b/app/src/main/assets/xposed_init index 572cbc0..36c4f0c 100644 --- a/app/src/main/assets/xposed_init +++ b/app/src/main/assets/xposed_init @@ -1 +1 @@ -cocobo1.pupu.xposed.Main \ No newline at end of file +ShiggyXposed.xposed.Main diff --git a/app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt deleted file mode 100644 index 0bd107e..0000000 --- a/app/src/main/kotlin/io/github/pupu/xposed/FontsModule.kt +++ /dev/null @@ -1,258 +0,0 @@ -// credits to janisslsm from his PR: https://github.com/vendetta-mod/VendettaXposed/pull/17 -// hooks are modified function from RN codebase - -package cocobo1.pupu.xposed - -import android.content.res.AssetManager -import android.os.Build -import android.graphics.Typeface -import android.graphics.Typeface.CustomFallbackBuilder -import android.graphics.fonts.Font -import android.graphics.fonts.FontFamily -import android.util.Log -import de.robv.android.xposed.XC_MethodReplacement -import de.robv.android.xposed.XposedHelpers -import de.robv.android.xposed.callbacks.XC_LoadPackage -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.* -import java.io.IOException -import java.io.File -import kotlinx.coroutines.* - -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.request.* -import io.ktor.client.engine.cio.* -import io.ktor.client.statement.* -import io.ktor.client.plugins.* -import io.ktor.http.* - -@Serializable -data class FontDefinition( - val name: String? = null, - val description: String? = null, - val spec: Int? = null, - val main: Map, -) - -class FontsModule: Module() { - private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") - private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") - private val FONTS_ASSET_PATH = "fonts/" - - private lateinit var fontsDir: File - private lateinit var fontsDownloadsDir: File - private var fontsAbsPath: String? = null - - override fun buildJson(builder: JsonObjectBuilder) { - builder.apply { - put("fontPatch", 2) - } - } - - private fun hookClass(classLoader: ClassLoader, className: String) { - XposedHelpers.findAndHookMethod( - className, - classLoader, - "createAssetTypeface", - String::class.java, - Int::class.java, - "android.content.res.AssetManager", - object : XC_MethodReplacement() { - override fun replaceHookedMethod(param: MethodHookParam): Typeface? { - val fontFamilyName: String = param.args[0].toString() - val style: Int = param.args[1] as Int - val assetManager: AssetManager = param.args[2] as AssetManager - return createAssetTypeface(fontFamilyName, style, assetManager) - } - }) - } - - override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) { - try { - // Try to hook on versions (280201+) - hookClass(classLoader, "com.facebook.react.views.text.ReactFontManager\$Companion") - } catch (e: Throwable) { - when (e) { - // Hook old class (280200-) - is NoClassDefFoundError, is XposedHelpers.ClassNotFoundError -> hookClass(classLoader, "com.facebook.react.views.text.ReactFontManager") - else -> throw e - } - } - - val fontDefFile = File(appInfo.dataDir, "files/pyoncord/fonts.json") - if (!fontDefFile.exists()) return@with - - val fontDef = try { - val json = Json { ignoreUnknownKeys = true } - json.decodeFromString(fontDefFile.readText()) - } catch (_: Throwable) { return@with } - - fontsDownloadsDir = File(appInfo.dataDir, "files/pyoncord/downloads/fonts").apply { mkdirs() } - fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { mkdirs() } - fontsAbsPath = fontsDir.absolutePath + "/" - - fontsDir.listFiles()?.forEach { file -> - val fileName = file.name - if (!fileName.startsWith(".")) { - val fontName = fileName.split('.')[0] - if (fontDef.main.keys.none { it == fontName }) { - Log.i("Kettu", "Deleting font file: $fileName") - file.delete() - } - } - } - - // These files should be downloaded by the JS side, but oh well - CoroutineScope(Dispatchers.IO).launch { - fontDef.main.keys.map { name -> - async { - val url = fontDef.main.getValue(name) - try { - Log.i("Kettu", "Downloading $name from $url") - val file = File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}") - if (file.exists()) return@async - - val client = HttpClient(CIO) { - install(UserAgent) { agent = "KettuXposed" } - } - - val response: HttpResponse = client.get(url) - - if (response.status == HttpStatusCode.OK) { - file.writeBytes(response.body()) - } - - return@async - } catch (e: Throwable) { - Log.e("Kettu", "Failed to download fonts ($name from $url)", e) - } - } - }.awaitAll() - } - - return@with - } - - private fun createAssetTypefaceWithFallbacks( - fontFamilyNames: Array, - style: Int, - assetManager: AssetManager - ): Typeface? { - val fontFamilies: MutableList = ArrayList() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Iterate over the list of fontFamilyNames, constructing new FontFamily objects - // for use in the CustomFallbackBuilder below. - for (fontFamilyName in fontFamilyNames) { - try { - for (fileExtension in FILE_EXTENSIONS) { - val (customName, refName) = fontFamilyName.split(":") - val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension") - val font = Font.Builder(file).build() - val family = FontFamily.Builder(font).build() - fontFamilies.add(family) - } - } catch (e: Throwable) { - // ignore - } - - for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { - for (fileExtension in FILE_EXTENSIONS) { - val fileName = java.lang.StringBuilder() - .append(fontRootPath) - .append(fontFamilyName) - .append(fileExtension) - .toString() - try { - val builder = if (fileName[0] == '/') Font.Builder(File(fileName)) else Font.Builder(assetManager, fileName) - val font = builder.build() - val family = FontFamily.Builder(font).build() - fontFamilies.add(family) - } catch (e: java.lang.RuntimeException) { - // If the typeface asset does not exist, try another extension. - continue - } catch (e: IOException) { - // If the font asset does not exist, try another extension. - continue - } - } - } - } - - // If there's some problem constructing fonts, fall back to the default behavior. - if (fontFamilies.size == 0) { - return createAssetTypeface(fontFamilyNames[0], style, assetManager) - } - val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0]) - for (i in 1 until fontFamilies.size) { - fallbackBuilder.addCustomFallback(fontFamilies[i]) - } - return fallbackBuilder.build() - } - return null - } - - private fun createAssetTypeface( - fontFamilyName_: String, style: Int, assetManager: AssetManager - ): Typeface? { - // This logic attempts to safely check if the frontend code is attempting to use - // fallback fonts, and if it is, to use the fallback typeface creation logic. - var fontFamilyName: String = fontFamilyName_ - val fontFamilyNames = - fontFamilyName.split(",".toRegex()) - .dropLastWhile { it.isEmpty() } - .toTypedArray() - for (i in fontFamilyNames.indices) { - fontFamilyNames[i] = fontFamilyNames[i].trim() - } - - // If there are multiple font family names: - // For newer versions of Android, construct a Typeface with fallbacks - // For older versions of Android, ignore all the fallbacks and just use the first font family - if (fontFamilyNames.size > 1) { - fontFamilyName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return createAssetTypefaceWithFallbacks(fontFamilyNames, style, assetManager) - } else { - fontFamilyNames[0] - } - } - - val extension = EXTENSIONS[style] - - try { - for (fileExtension in FILE_EXTENSIONS) { - val (customName, refName) = fontFamilyName.split(":") - val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension") - if (!file.exists()) throw Exception() - return Typeface.createFromFile(file.absolutePath) - } - } catch (e: Throwable) { - // ignore - } - - // Lastly, after all those checks above, this is the original RN logic for - // getting the typeface. - for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { - for (fileExtension in FILE_EXTENSIONS) { - val fileName = java.lang.StringBuilder() - .append(fontRootPath) - .append(fontFamilyName) - .append(extension) - .append(fileExtension) - .toString() - - return try { - if (fileName[0] == '/') - Typeface.createFromFile(fileName) - else - Typeface.createFromAsset(assetManager, fileName) - } catch (e: java.lang.RuntimeException) { - // If the typeface asset does not exist, try another extension. - continue - } - } - } - - return Typeface.create(fontFamilyName, style) - } -} \ 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/pupu/xposed/SysColorsModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/SysColorsModule.kt deleted file mode 100644 index 4f400a8..0000000 --- a/app/src/main/kotlin/io/github/pupu/xposed/SysColorsModule.kt +++ /dev/null @@ -1,50 +0,0 @@ -package cocobo1.pupu.xposed - -import android.app.AndroidAppHelper -import android.content.Context -import android.os.Build -import androidx.core.content.ContextCompat -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.* - -@Serializable -data class SysColors( - val neutral1: List, - 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) { - context = AndroidAppHelper.currentApplication() - val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2") - val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000) - - builder.apply { - put("isSysColorsSupported", isSupported()) - if (isSupported()) putJsonObject("sysColors") { - for (accent in accents) putJsonArray(accent) { - for (shade in shades) { - val colorName = "system_" + accent + "_" + shade - - val colorResourceId = runCatching { - android.R.color::class.java.getField(colorName).getInt(null) - }.getOrElse { 0 } - - add(convertToColor(colorResourceId)) - } - } - } - } - } - - private fun convertToColor(id: Int): String { - val clr = if (isSupported()) ContextCompat.getColor(context, id) else 0 - return String.format("#%06X", 0xFFFFFF and clr) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt b/app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt deleted file mode 100644 index 3caf491..0000000 --- a/app/src/main/kotlin/io/github/pupu/xposed/ThemeModule.kt +++ /dev/null @@ -1,165 +0,0 @@ -package cocobo1.pupu.xposed - -import android.content.Context -import android.graphics.Color -import android.content.res.Resources -import de.robv.android.xposed.XC_MethodHook -import de.robv.android.xposed.XposedBridge -import de.robv.android.xposed.callbacks.XC_LoadPackage -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.io.File -import kotlinx.serialization.json.* - -@Serializable -data class Author( - val name: String, - val id: String? = null -) -@Serializable -data class ThemeData( - val name: String, - val description: String? = null, - val authors: List? = null, - val spec: Int, - val semanticColors: Map>? = null, - val rawColors: Map? = null -) -@Serializable -data class Theme( - val id: String, - val selected: Boolean, - val data: ThemeData -) - -class ThemeModule : Module() { - private lateinit var param: XC_LoadPackage.LoadPackageParam - - private var theme: Theme? = null - private val rawColorMap = mutableMapOf() - - @ExperimentalSerializationApi - override fun buildJson(builder: JsonObjectBuilder) { - builder.apply { - put("hasThemeSupport", true) - if (theme != null) - put("storedTheme", Json.encodeToJsonElement(theme!!)) - else - put("storedTheme", null) - } - } - - override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) { - param = packageParam - theme = getTheme() - hookTheme() - } - - private fun File.isValidish(): Boolean { - if (!this.exists()) return false - - val text = this.readText() - return text.isNotBlank() && text != "{}" && text != "null" - } - - private fun getTheme(): Theme? { - val filesDir = File(param.appInfo.dataDir, "files").apply { mkdirs() } - val pyonDir = File(filesDir, "pyoncord").apply { mkdirs() } - val themeFile = File(pyonDir, "current-theme.json") - - val legacyThemeFile = File(filesDir, "vendetta_theme.json") - if (legacyThemeFile.isValidish() && !themeFile.isValidish()) { - legacyThemeFile.copyTo(themeFile, overwrite = true) - } - - if (!themeFile.isValidish()) return null - - return try { - val themeText = themeFile.readText() - Json { ignoreUnknownKeys = true }.decodeFromString(themeText) - } catch (e: Exception) { null } - } - - fun hookTheme() { - val themeManager = param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt") - val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme") - val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme") - - val theme = this.theme - if (theme == null) return - - // Apply rawColors - theme.data.rawColors?.forEach { (key, value) -> - rawColorMap[key.lowercase()] = hexStringToColorInt(value) - } - - // Apply semanticColors - theme.data.semanticColors?.forEach { (key, value) -> - // TEXT_NORMAL -> getTextNormal - val methodName = "get${key.split("_").joinToString("") { it.lowercase().replaceFirstChar { it.uppercase() } }}" - value.forEachIndexed { index, v -> - when (index) { - 0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v)) - 1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v)) - } - } - } - - // If there's any rawColors value, hook the color getter - if (!theme.data.rawColors.isNullOrEmpty()) { - val getColorCompat = themeManager.getDeclaredMethod( - "getColorCompat", - Resources::class.java, - Int::class.javaPrimitiveType, - Resources.Theme::class.java, - ) - - val getColorCompatLegacy = themeManager.getDeclaredMethod( - "getColorCompat", - Context::class.java, - Int::class.javaPrimitiveType - ) - - val patch = object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - val arg1 = param.args[0] - val resources = if (arg1 is Context) arg1.resources else (arg1 as Resources) - val name = resources.getResourceEntryName(param.args[1] as Int) - - if (rawColorMap[name] != null) param.result = rawColorMap[name] - } - } - - XposedBridge.hookMethod(getColorCompat, patch) - XposedBridge.hookMethod(getColorCompatLegacy, patch) - } - } - - // Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB" - private fun hexStringToColorInt(hexString: String): Int { - return if (hexString.length == 9 ) { - // Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy - val alpha = hexString.substring(7, 9) - val rrggbb = hexString.substring(1, 7) - Color.parseColor("#$alpha$rrggbb") - } else Color.parseColor(hexString) - } - - private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) { - try { - themeClass.getDeclaredMethod(methodName).let { method -> - // Log.i("Hooking $methodName -> ${themeValue.toString(16)}") - XposedBridge.hookMethod(method, object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - param.result = themeValue - } - }) - } - } catch (ex: NoSuchMethodException) { - // do nothing - } - } -} \ 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..86245e2 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt @@ -0,0 +1,18 @@ +package ShiggyXposed.xposed + +class Constants { + companion object { + const val TARGET_PACKAGE = "com.discord" + const val TARGET_ACTIVITY = "$TARGET_PACKAGE.react_activities.ReactActivity" + + const val FILES_DIR = "files/pyoncord" + const val CACHE_DIR = "cache/pyoncord" + const val MAIN_SCRIPT_FILE = "bundle.js" + + const val LOG_TAG = "ShiggyXposed" + + const val LOADER_NAME = "ShiggyXposed" + + const val USER_AGENT = "ShiggyXposed" + } +} 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..38118cc --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/Main.kt @@ -0,0 +1,70 @@ +package ShiggyXposed.xposed + +import ShiggyXposed.xposed.modules.* +import ShiggyXposed.xposed.modules.appearance.FontsModule +import ShiggyXposed.xposed.modules.appearance.SysColorsModule +import ShiggyXposed.xposed.modules.appearance.ThemesModule +import ShiggyXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule +import ShiggyXposed.xposed.modules.bridge.BridgeModule +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.os.Bundle +import de.robv.android.xposed.IXposedHookLoadPackage +import de.robv.android.xposed.IXposedHookZygoteInit +import de.robv.android.xposed.callbacks.XC_LoadPackage + +class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit { + private var hooked = false + private val modules = + mutableListOf( + HookScriptLoaderModule(), + BridgeModule(), + AdditionalBridgeMethodsModule(), + UpdaterModule(), + FixResourcesModule(), + LogBoxModule(), + FontsModule(), + ThemesModule(), + SysColorsModule() + ) + + init { + modules.add(PayloadGlobalModule(modules)) + } + + override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) { + for (module in modules) module.onInit(startupParam) + } + + override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = + with(param) { + if (hooked) return + + val reactActivity = classLoader.loadClass(Constants.TARGET_ACTIVITY) + + ContextWrapper::class.java.hookMethod("attachBaseContext", Context::class.java) { + after { this@Main.onContext(args[0] as Context) } + } + + reactActivity.hookMethod("onCreate", Bundle::class.java) { + after { this@Main.onActivity(thisObject as Activity) } + } + + this@Main.onLoad(param) + + hooked = true + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { + for (module in modules) module.onLoad(packageParam) + } + + override fun onContext(context: Context) { + for (module in modules) module.onContext(context) + } + + override fun onActivity(activity: Activity) { + for (module in modules) module.onActivity(activity) + } +} 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..8813346 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/Module.kt @@ -0,0 +1,192 @@ +package ShiggyXposed.xposed + +import android.app.Activity +import android.content.Context +import android.os.Build +import de.robv.android.xposed.IXposedHookZygoteInit +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage +import java.io.File +import java.lang.reflect.Method +import kotlinx.serialization.json.JsonObjectBuilder + +data class AppInfo( + val name: String, + val packageName: String, + val version: String, + val versionCode: Long, +) + +abstract class Module { + /** Builds a JSON payload to be injected into the JavaScript context. */ + @Deprecated( + "This will be removed in future versions. Payloads can be replaced via synchronous bridge methods." + ) + open fun buildPayload(builder: JsonObjectBuilder) {} + + /** Called during Zygote initialization. */ + open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {} + + /** + * Called when a package is loaded. + * + * Hooks should typically be set up here. + * + * Bridge methods that do not require [Context] can also be registered here. + */ + open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {} + + /** + * Called after [Context] has been attached to a [android.content.ContextWrapper] via + * [android.content.ContextWrapper.attachBaseContext]. + * + * This may be called multiple times for different [Context]s, e.g. when the app is killed by + * the system. + * + * For bridge methods that require access to [Context], register them here. + */ + open fun onContext(context: Context) {} + + /** + * Called after an [Activity.onCreate] method is executed. + * + * UI code that requires an [Activity] context can be placed here. + * + * Bridge methods must not be registered here, as it can be too late and cause crashes. + * + * If you need to register bridge methods that require an [Activity], do it in [onContext] and + * make it async. + * + * Once an [Activity] is available, execute the queued calls. Then from JS, call the method + * asynchronously. + */ + open fun onActivity(activity: Activity) {} + + protected fun Context.getAppInfo(): AppInfo { + val pInfo = packageManager.getPackageInfo(packageName, 0) + val versionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode + else @Suppress("DEPRECATION") pInfo.versionCode.toLong() + + return AppInfo( + packageManager.getApplicationLabel(applicationInfo).toString(), + packageName, + pInfo.versionName ?: versionCode.toString(), + versionCode, + ) + } + + protected fun File.asDir() { + if (!this.isDirectory) this.delete() + this.mkdirs() + } + + protected fun File.asFile() { + if (!this.isFile) this.deleteRecursively() + } + + protected fun Class<*>.method(name: String, vararg params: Class<*>?): Method = + getDeclaredMethod(name, *params).apply { isAccessible = true } + + protected fun ClassLoader.safeLoadClass(name: String): Class<*>? = + runCatching { loadClass(name) }.getOrNull() + + protected fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook = + XposedBridge.hookMethod(this, hook) + + protected fun Method.hook(block: MethodHookBuilder.() -> Unit): XC_MethodHook.Unhook = + hook(MethodHookBuilder().apply(block).build()) + + protected fun Class<*>.hookMethod( + name: String, + vararg params: Class<*>?, + block: MethodHookBuilder.() -> Unit + ): XC_MethodHook.Unhook = method(name, *params).hook(MethodHookBuilder().apply(block).build()) + + protected class MethodHookBuilder { + private var beforeBlock: (HookScope.() -> Unit)? = null + private var afterBlock: (HookScope.() -> Unit)? = null + + fun before(block: HookScope.() -> Unit) { + beforeBlock = block + } + + fun after(block: HookScope.() -> Unit) { + afterBlock = block + } + + fun build(): XC_MethodHook = + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val b = beforeBlock + if (b != null) { + val scope = + HookScope( + param = param, + proceed = { p -> super.beforeHookedMethod(p) } + ) + scope.b() + } else { + super.beforeHookedMethod(param) + } + } + + override fun afterHookedMethod(param: MethodHookParam) { + val a = afterBlock + if (a != null) { + val scope = + HookScope( + param = param, + proceed = { p -> super.afterHookedMethod(p) } + ) + scope.a() + } else { + super.afterHookedMethod(param) + } + } + } + } + + /** + * Scope object passed to before/after hook blocks. + * + * Provides: + * - Access to the [param] object + * - [proceed] to call the original XC_MethodHook super method + * - Accessors for `thisObject`, `args`, `result`, and `throwable` + * + * @property param The [XC_MethodHook.MethodHookParam] for the current hook. + * @property proceed Function that calls the super method. + */ + protected class HookScope + internal constructor( + val param: XC_MethodHook.MethodHookParam, + private val proceed: (XC_MethodHook.MethodHookParam) -> Unit + ) { + /** + * Continues with the default XC_MethodHook super behavior. Equivalent to calling + * `super.beforeHookedMethod(param)` or `super.afterHookedMethod(param)` depending on the + * phase. + */ + fun proceed() = proceed(param) + + val thisObject: Any? + get() = param.thisObject + + val args: Array + 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 + } + } +} 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..a5c2e68 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/Utils.kt @@ -0,0 +1,32 @@ +package ShiggyXposed.xposed + +import android.app.AndroidAppHelper +import android.content.Intent +import kotlin.system.exitProcess +import kotlinx.serialization.json.Json + +class Utils { + companion object { + val JSON = Json { ignoreUnknownKeys = true } + + fun reloadApp() { + val application = AndroidAppHelper.currentApplication() + val intent = + application.packageManager.getLaunchIntentForPackage(application.packageName) + application.startActivity(Intent.makeRestartActivityTask(intent!!.component)) + exitProcess(0) + } + } + + object Log { + fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg) + fun e(msg: String, throwable: Throwable) = + android.util.Log.e(Constants.LOG_TAG, msg, throwable) + fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg) + fun i(msg: String, throwable: Throwable) = + android.util.Log.i(Constants.LOG_TAG, msg, throwable) + fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg) + fun w(msg: String, throwable: Throwable) = + android.util.Log.w(Constants.LOG_TAG, msg, throwable) + } +} 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..b0bee81 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt @@ -0,0 +1,21 @@ +package ShiggyXposed.xposed.modules + +import ShiggyXposed.xposed.Constants.Companion.TARGET_PACKAGE +import ShiggyXposed.xposed.Module +import android.content.res.Resources +import de.robv.android.xposed.callbacks.XC_LoadPackage + +/** Hooks [Resources.getIdentifier] to fix resource package name mismatch. */ +class FixResourcesModule : Module() { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = + with(packageParam) { + if (packageName != TARGET_PACKAGE) { + Resources::class.java.hookMethod( + "getIdentifier", + String::class.java, + String::class.java, + String::class.java + ) { before { if (args[2] == packageName) args[2] = TARGET_PACKAGE } } + } + } +} 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..35346ee --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt @@ -0,0 +1,132 @@ +package ShiggyXposed.xposed.modules + +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR +import android.content.res.AssetManager +import android.content.res.XModuleResources +import de.robv.android.xposed.IXposedHookZygoteInit +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage +import java.io.File +import java.lang.reflect.Method +import kotlinx.coroutines.runBlocking + +/** + * Hooks React Native's script loading methods to load custom scripts and bundles. + * + * Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files + * directory. + * + * The main bundle should be placed in the [Constants.CACHE_DIR] directory named + * [Constants.MAIN_SCRIPT_FILE]. If the bundle file does not exist, it will attempt to load + * `assets://revenge.bundle` from the module's assets. + */ +class HookScriptLoaderModule : Module() { + private lateinit var preloadsDir: File + private lateinit var mainScript: File + + private lateinit var modulePath: String + private lateinit var resources: XModuleResources + + companion object { + const val PRELOADS_DIR = "preloads" + } + + override fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) { + this@HookScriptLoaderModule.modulePath = startupParam.modulePath + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = + with(packageParam) { + val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() } + val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() } + + preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() } + mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() } + + listOf( + "com.facebook.react.runtime.ReactInstance$1", + // TODO: Remove once Discord fully switches to Bridgeless + "com.facebook.react.bridge.CatalystInstanceImpl" + ) + .mapNotNull { classLoader.safeLoadClass(it) } + .forEach { hook(it) } + } + + private fun hook(instance: Class<*>) { + val loadScriptFromAssets = + instance.method( + "loadScriptFromAssets", + AssetManager::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ) + + val loadScriptFromFile = + instance.method( + "loadScriptFromFile", + String::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ) + + loadScriptFromAssets.hook { + before { + Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})") + runCustomScripts(loadScriptFromFile, loadScriptFromAssets) + } + } + + loadScriptFromFile.hook { + before { + Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})") + runCustomScripts(loadScriptFromFile, loadScriptFromAssets) + } + } + } + + private fun HookScope.runCustomScripts( + loadScriptFromFile: Method, + loadScriptFromAssets: Method + ) { + Log.i("Running custom scripts...") + + // TODO: Is there a better way to do this? + runBlocking { UpdaterModule.job?.join() } + + val loadSynchronously = args[2] + val runScriptFile = { file: File -> + Log.i("Loading script: ${file.absolutePath}") + + XposedBridge.invokeOriginalMethod( + loadScriptFromFile, + thisObject, + arrayOf(file.absolutePath, file.absolutePath, loadSynchronously) + ) + + Unit + } + + try { + preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile) + + if (mainScript.exists()) runScriptFile(mainScript) + else { + Log.i("Main script does not exist, falling back") + + if (!::resources.isInitialized) + resources = XModuleResources.createInstance(modulePath, null) + + XposedBridge.invokeOriginalMethod( + loadScriptFromAssets, + thisObject, + arrayOf(resources.assets, "assets://bundle.js", loadSynchronously) + ) + } + } catch (e: Throwable) { + Log.e("Unable to run scripts:", e) + } + } +} 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..5ef94c5 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt @@ -0,0 +1,128 @@ +package ShiggyXposed.xposed.modules + +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.reloadApp +import ShiggyXposed.xposed.Utils.Log +import android.app.AlertDialog +import android.content.Context +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import de.robv.android.xposed.callbacks.XC_LoadPackage +import java.io.File + +class LogBoxModule : Module() { + lateinit var packageParam: XC_LoadPackage.LoadPackageParam + lateinit var bridgeDevSupportManagerClass: Class<*> + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = + with(packageParam) { + this@LogBoxModule.packageParam = packageParam + + try { + val dcdReactNativeHostClass = + classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") + bridgeDevSupportManagerClass = + classLoader.loadClass( + "com.facebook.react.devsupport.BridgeDevSupportManager" + ) + + val getUseDeveloperSupportMethod = + dcdReactNativeHostClass.methods.first { + it.name == "getUseDeveloperSupport" + } + val handleReloadJSMethod = + bridgeDevSupportManagerClass.methods.first { + it.name == "handleReloadJS" + } + val showDevOptionsDialogMethod = + bridgeDevSupportManagerClass.methods.first { + it.name == "showDevOptionsDialog" + } + val mReactInstanceDevHelperField = + XposedHelpers.findField( + bridgeDevSupportManagerClass, + "mReactInstanceDevHelper" + ) + + // This enables the LogBox and opens dev option on shake + XposedBridge.hookMethod( + getUseDeveloperSupportMethod, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + param.result = true + } + } + ) + + // Replace the method to direct relaunch the app instead of sending reload + // command to developer server + XposedBridge.hookMethod( + handleReloadJSMethod, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + reloadApp() + param.result = null + } + } + ) + + // Triggered on shake - Moved from onContext to onLoad + XposedBridge.hookMethod( + showDevOptionsDialogMethod, + object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + try { + val mReactInstanceDevHelper = + mReactInstanceDevHelperField.get(param.thisObject) + val getCurrentActivityMethod = + mReactInstanceDevHelper.javaClass.methods.first { + it.name == "getCurrentActivity" + } + val context = + getCurrentActivityMethod.invoke( + mReactInstanceDevHelper + ) as + Context + + showRecoveryAlert(context) + } catch (ex: Exception) { + Log.e("Failed to show dev options dialog: $ex") + } + + // Ignore the original dev menu + param.result = null + } + } + ) + } catch (e: Exception) { + Log.e("Error in LogBoxModule.onLoad: $e") + } + + return@with + } + + private fun showRecoveryAlert(context: Context) { + AlertDialog.Builder(context) + .setTitle("ShiggyXposed Recovery Options") + .setItems(arrayOf("Reload", "Delete bundle.js")) { _, which -> + when (which) { + 0 -> { + reloadApp() + } + 1 -> { + val bundleFile = + File( + packageParam.appInfo.dataDir, + "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}" + ) + if (bundleFile.exists()) bundleFile.delete() + + reloadApp() + } + } + } + .show() + } +} 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..17a6de5 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt @@ -0,0 +1,108 @@ +package ShiggyXposed.xposed.modules + +import ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR +import android.content.res.AssetManager +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage +import java.io.File +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule +// if needed +class PayloadGlobalModule(private val modules: List) : Module() { + 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) + } +} 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..a640780 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt @@ -0,0 +1,148 @@ +package ShiggyXposed.xposed.modules + +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.Utils.Log +import android.app.Activity +import android.util.AtomicFile +import androidx.core.util.writeBytes +import de.robv.android.xposed.callbacks.XC_LoadPackage +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import java.io.File +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable + +@Serializable data class CustomLoadUrl(val enabled: Boolean = false, val url: String = "") + +@Serializable data class LoaderConfig(val customLoadUrl: CustomLoadUrl = CustomLoadUrl()) + +/** + * Module that updates the JS bundle by downloading it from a remote URL. + * + * Shows dialogs when failed allowing retry. + */ +class UpdaterModule : Module() { + private lateinit var config: LoaderConfig + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var error: Throwable? = null + + private lateinit var cacheDir: File + private lateinit var bundle: File + private lateinit var etag: File + + companion object { + var job: Job? = null + + private const val TIMEOUT_CACHED = 5000L + private const val TIMEOUT = 50000L + private const val ETAG_FILE = "etag.txt" + private const val CONFIG_FILE = "loader.json" + + private const val DEFAULT_BUNDLE_URL = + "https://github.com/kmmiio99o/ShiggyCord/releases/download/v1.0.0/shiggycord.js" + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = + with(packageParam) { + cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() } + val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() } + + bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE) + etag = File(cacheDir, ETAG_FILE) + + val configFile = File(filesDir, CONFIG_FILE) + + config = + runCatching { + if (configFile.exists()) { + JSON.decodeFromString(configFile.readText()) + } else LoaderConfig() + } + .getOrDefault(LoaderConfig()) + + downloadScript() + } + + private fun downloadScript(activity: Activity? = null) { + job = + scope.launch { + try { + HttpClient(CIO) { + expectSuccess = false + install(UserAgent) { agent = Constants.USER_AGENT } + install(HttpRedirect) {} + } + .use { client -> + val url = + config.customLoadUrl.takeIf { it.enabled }?.url + ?: DEFAULT_BUNDLE_URL + Log.i("Fetching JS bundle from: $url") + + val response: HttpResponse = + client.get(url) { + headers { + if (etag.exists() && bundle.exists()) { + append( + HttpHeaders.IfNoneMatch, + etag.readText() + ) + } + } + + // Retries don't need timeout + if (activity != null) { + timeout { + requestTimeoutMillis = + if (!bundle.exists()) TIMEOUT + else TIMEOUT_CACHED + } + } + } + + when (response.status) { + HttpStatusCode.OK -> { + val bytes: ByteArray = response.body() + AtomicFile(bundle).writeBytes(bytes) + + val newTag = response.headers[HttpHeaders.ETag] + if (!newTag.isNullOrEmpty()) etag.writeText(newTag) + else etag.delete() + + Log.i("Bundle updated (${bytes.size} bytes)") + + // This is a retry, so we show a dialog + if (activity != null) { + withContext(Dispatchers.Main) {} + } + } + HttpStatusCode.NotModified -> { + Log.i("Server responded with 304, no changes") + } + else -> { + throw ResponseException( + response, + "Received status: ${response.status}" + ) + } + } + } + } catch (e: Throwable) { + Log.e("Failed to download script", e) + error = e + } + } + } + + override fun onActivity(activity: Activity) { + error ?: return + error = null + downloadScript(activity) + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt new file mode 100644 index 0000000..1b62ebb --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt @@ -0,0 +1,271 @@ +package ShiggyXposed.xposed.modules.appearance + +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.Utils.Log +import android.content.res.AssetManager +import android.graphics.Typeface +import android.graphics.Typeface.CustomFallbackBuilder +import android.graphics.fonts.Font +import android.graphics.fonts.FontFamily +import android.os.Build +import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XposedHelpers +import de.robv.android.xposed.callbacks.XC_LoadPackage +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import java.io.File +import java.io.IOException +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.put + +@Serializable +data class FontDefinition( + val name: String? = null, + val description: String? = null, + val spec: Int? = null, + val main: Map, +) + +class FontsModule : Module() { + private companion object { + val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") + val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") + const val FONTS_ASSET_PATH = "fonts/" + } + + private lateinit var fontsDir: File + private lateinit var fontsDownloadsDir: File + private var fontsAbsPath: String? = null + + override fun buildPayload(builder: JsonObjectBuilder) { + builder.apply { put("fontPatch", 2) } + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = + with(packageParam) { + XposedHelpers.findAndHookMethod( + "com.facebook.react.views.text.ReactFontManager\$Companion", + classLoader, + "createAssetTypeface", + String::class.java, + Int::class.java, + "android.content.res.AssetManager", + object : XC_MethodReplacement() { + override fun replaceHookedMethod(param: MethodHookParam): Typeface? { + val fontFamilyName: String = param.args[0].toString() + val style: Int = param.args[1] as Int + val assetManager: AssetManager = param.args[2] as AssetManager + return createAssetTypeface(fontFamilyName, style, assetManager) + } + } + ) + + val fontDefFile = + File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply { + asFile() + } + if (!fontDefFile.exists()) return@with + + val fontDef = + try { + JSON.decodeFromString(fontDefFile.readText()) + } catch (_: Throwable) { + return@with + } + + fontsDownloadsDir = + File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { + asDir() + } + fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() } + fontsAbsPath = fontsDir.absolutePath + "/" + + fontsDir.listFiles()?.forEach { file -> + val fileName = file.name + if (!fileName.startsWith(".")) { + val fontName = fileName.split('.')[0] + if (fontDef.main.keys.none { it == fontName }) { + Log.i("Deleting font file: $fileName") + file.delete() + } + } + } + + // These files should be downloaded by the JS side, but oh well + CoroutineScope(Dispatchers.IO).launch { + fontDef.main + .keys + .map { name -> + async { + val url = fontDef.main.getValue(name) + try { + Log.i("Downloading $name from $url") + val file = + File( + fontsDir, + "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}" + ) + .apply { asFile() } + if (file.exists()) return@async + + val client = + HttpClient(CIO) { + install(UserAgent) { + agent = Constants.USER_AGENT + } + } + + val response: HttpResponse = client.get(url) + if (response.status == HttpStatusCode.OK) + file.writeBytes(response.body()) + + return@async + } catch (e: Throwable) { + Log.e("Failed to download fonts ($name from $url)", e) + } + } + } + .awaitAll() + } + } + + private fun createAssetTypefaceWithFallbacks( + fontFamilyNames: Array, + style: Int, + assetManager: AssetManager + ): Typeface? { + val fontFamilies: MutableList = ArrayList() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Iterate over the list of fontFamilyNames, constructing new FontFamily objects + // for use in the CustomFallbackBuilder below. + for (fontFamilyName in fontFamilyNames) { + try { + for (fileExtension in FILE_EXTENSIONS) { + val (customName, refName) = fontFamilyName.split(":") + val file = + File(fontsDownloadsDir, "$customName/$refName.$fileExtension") + .apply { asFile() } + val font = Font.Builder(file).build() + val family = FontFamily.Builder(font).build() + fontFamilies.add(family) + } + } catch (_: Throwable) {} + + for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { + for (fileExtension in FILE_EXTENSIONS) { + val fileName = + StringBuilder() + .append(fontRootPath) + .append(fontFamilyName) + .append(fileExtension) + .toString() + try { + val builder = + if (fileName[0] == '/') Font.Builder(File(fileName)) + else Font.Builder(assetManager, fileName) + val font = builder.build() + val family = FontFamily.Builder(font).build() + fontFamilies.add(family) + } catch (_: RuntimeException) { + // If the typeface asset does not exist, try another extension. + continue + } catch (_: IOException) { + // If the font asset does not exist, try another extension. + continue + } + } + } + } + + // If there's some problem constructing fonts, fall back to the default behavior. + if (fontFamilies.isEmpty()) + return createAssetTypeface(fontFamilyNames[0], style, assetManager) + + val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0]) + for (i in 1 until fontFamilies.size) { + fallbackBuilder.addCustomFallback(fontFamilies[i]) + } + return fallbackBuilder.build() + } + return null + } + + private fun createAssetTypeface( + fontFamilyName: String, + style: Int, + assetManager: AssetManager + ): Typeface? { + // This logic attempts to safely check if the frontend code is attempting to use + // fallback fonts, and if it is, to use the fallback typeface creation logic. + var fontFamilyName: String = fontFamilyName + val fontFamilyNames = + fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + for (i in fontFamilyNames.indices) { + fontFamilyNames[i] = fontFamilyNames[i].trim() + } + + // If there are multiple font family names: + // For newer versions of Android, construct a Typeface with fallbacks + // For older versions of Android, ignore all the fallbacks and just use the first font + // family + if (fontFamilyNames.size > 1) { + fontFamilyName = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return createAssetTypefaceWithFallbacks( + fontFamilyNames, + style, + assetManager + ) + } else { + fontFamilyNames[0] + } + } + + val extension = EXTENSIONS[style] + + try { + for (fileExtension in FILE_EXTENSIONS) { + val (customName, refName) = fontFamilyName.split(":") + val file = + File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { + asFile() + } + if (!file.exists()) throw Exception() + return Typeface.createFromFile(file.absolutePath) + } + } catch (_: Throwable) {} + + // Lastly, after all those checks above, this is the original RN logic for + // getting the typeface. + for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { + for (fileExtension in FILE_EXTENSIONS) { + val fileName = + StringBuilder() + .append(fontRootPath) + .append(fontFamilyName) + .append(extension) + .append(fileExtension) + .toString() + + return try { + if (fileName[0] == '/') Typeface.createFromFile(fileName) + else Typeface.createFromAsset(assetManager, fileName) + } catch (_: RuntimeException) { + // If the typeface asset does not exist, try another extension. + continue + } + } + } + + return Typeface.create(fontFamilyName, style) + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt new file mode 100644 index 0000000..121c86a --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt @@ -0,0 +1,45 @@ +package ShiggyXposed.xposed.modules.appearance + +import ShiggyXposed.xposed.Module +import android.R.color +import android.app.AndroidAppHelper +import android.content.Context +import android.os.Build +import androidx.core.content.ContextCompat +import kotlinx.serialization.json.* + +class SysColorsModule : Module() { + private lateinit var context: Context + private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + override fun buildPayload(builder: JsonObjectBuilder) { + context = AndroidAppHelper.currentApplication() + val accents = arrayOf("accent1", "accent2", "accent3", "neutral1", "neutral2") + val shades = arrayOf(0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000) + + builder.apply { + put("isSysColorsSupported", isSupported()) + if (isSupported()) + putJsonObject("sysColors") { + for (accent in accents) putJsonArray(accent) { + for (shade in shades) { + val colorName = "system_" + accent + "_" + shade + + val colorResourceId = + runCatching { + color::class.java.getField(colorName).getInt(null) + } + .getOrElse { 0 } + + add(convertToColor(colorResourceId)) + } + } + } + } + } + + private fun convertToColor(id: Int): String { + val clr = if (isSupported()) ContextCompat.getColor(context, id) else 0 + return String.format("#%06X", 0xFFFFFF and clr) + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt new file mode 100644 index 0000000..87f372a --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt @@ -0,0 +1,160 @@ +package ShiggyXposed.xposed.modules.appearance + +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import android.content.Context +import android.content.res.Resources +import androidx.core.graphics.toColorInt +import de.robv.android.xposed.callbacks.XC_LoadPackage +import java.io.File +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put + +@Serializable data class Author(val name: String, val id: String? = null) + +@Serializable +data class ThemeData( + val name: String, + val description: String? = null, + val authors: List? = null, + val spec: Int, + val semanticColors: Map>? = null, + val rawColors: Map? = null +) + +@Serializable data class Theme(val id: String, val selected: Boolean, val data: ThemeData) + +class ThemesModule : Module() { + private lateinit var param: XC_LoadPackage.LoadPackageParam + + private var theme: Theme? = null + private val rawColorMap = mutableMapOf() + + private companion object { + const val THEME_FILE = "current-theme.json" + } + + @ExperimentalSerializationApi + override fun buildPayload(builder: JsonObjectBuilder) { + builder.apply { + put("hasThemeSupport", true) + if (theme != null) put("storedTheme", JSON.encodeToJsonElement(theme!!)) + else put("storedTheme", null) + } + } + + private fun String.fromScreamingSnakeToCamelCase() = + this.split("_").joinToString("") { it -> + it.lowercase().replaceFirstChar { it.uppercase() } + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { + param = packageParam + theme = getTheme() + hookTheme() + } + + private fun File.isValidish(): Boolean { + if (!this.exists()) return false + + val text = this.readText() + return text.isNotBlank() && text != "{}" && text != "null" + } + + private fun getTheme(): Theme? { + val themeFile = + File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { + asFile() + } + if (!themeFile.isValidish()) return null + + return try { + val themeText = themeFile.readText() + JSON.decodeFromString(themeText) + } catch (_: Exception) { + null + } + } + + fun hookTheme() { + val themeManager = param.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt") + val darkTheme = param.classLoader.loadClass("com.discord.theme.DarkerTheme") + val lightTheme = param.classLoader.loadClass("com.discord.theme.LightTheme") + + val theme = this.theme ?: return + + // Apply rawColors + theme.data.rawColors?.forEach { (key, value) -> + rawColorMap[key.lowercase()] = hexStringToColorInt(value) + } + + // Apply semanticColors + theme.data.semanticColors?.forEach { (key, value) -> + // TEXT_NORMAL -> getTextNormal + val methodName = "get${key.fromScreamingSnakeToCamelCase()}" + value.forEachIndexed { index, v -> + when (index) { + 0 -> hookThemeMethod(darkTheme, methodName, hexStringToColorInt(v)) + 1 -> hookThemeMethod(lightTheme, methodName, hexStringToColorInt(v)) + } + } + } + + // If there's any rawColors value, hook the color getter + if (!theme.data.rawColors.isNullOrEmpty()) { + val getColorCompat = + themeManager.getDeclaredMethod( + "getColorCompat", + Resources::class.java, + Int::class.javaPrimitiveType, + Resources.Theme::class.java, + ) + + val getColorCompatLegacy = + themeManager.getDeclaredMethod( + "getColorCompat", + Context::class.java, + Int::class.javaPrimitiveType + ) + + val patch = + MethodHookBuilder().run { + before { + val arg1 = args[0] + val resources = + if (arg1 is Context) arg1.resources else (arg1 as Resources) + val name = resources.getResourceEntryName(args[1] as Int) + + if (rawColorMap[name] != null) result = rawColorMap[name] + } + + build() + } + + getColorCompat.hook(patch) + getColorCompatLegacy.hook(patch) + } + } + + // Parse HEX colour string to INT. Takes "#RRGGBBAA" or "#RRGGBB" + private fun hexStringToColorInt(hexString: String): Int { + return if (hexString.length == 9) { + // Rearrange RRGGBBAA -> AARRGGBB so parseColor() is happy + val alpha = hexString.substring(7, 9) + val rrggbb = hexString.substring(1, 7) + "#$alpha$rrggbb".toColorInt() + } else hexString.toColorInt() + } + + private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) { + try { + themeClass.getDeclaredMethod(methodName).let { method -> + method.hook { before { result = themeValue } } + } + } catch (_: NoSuchMethodException) {} + } +} 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..74bcc4b --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt @@ -0,0 +1,52 @@ +package ShiggyXposed.xposed.modules.bridge + +import ShiggyXposed.xposed.Module +import android.app.AlertDialog +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.widget.Toast + +class AdditionalBridgeMethodsModule : Module() { + override fun onContext(context: Context) = + with(context) { + BridgeModule.registerMethod("revenge.alertError") { + val (error, version) = it + val app = getAppInfo() + val errorString = "$error" + + val clipboard = + applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as + ClipboardManager + val clip = ClipData.newPlainText("Stack Trace", errorString) + + AlertDialog.Builder(this) + .setTitle("ShiggyXposed Error") + .setMessage( + """ + ShiggyXposed: $version + ${app.name}: ${app.version} (${app.versionCode}) + Device: ${Build.MANUFACTURER} ${Build.MODEL} + + + """.trimIndent() + + errorString + ) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .setNeutralButton("Copy") { dialog, _ -> + clipboard.setPrimaryClip(clip) + Toast.makeText( + applicationContext, + "Copied stack trace", + Toast.LENGTH_SHORT + ) + .show() + dialog.dismiss() + } + .show() + + null + } + } +} 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..eac6d27 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt @@ -0,0 +1,202 @@ +package ShiggyXposed.xposed.modules.bridge + +import ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import de.robv.android.xposed.callbacks.XC_LoadPackage +import java.io.File +import java.lang.reflect.Method + +/** + * See for possible return types: + * https://github.com/facebook/react-native/blob/c23e84ae9/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/Arguments.kt#L19 + * + * You may return a [Unit] and the resulting value will be `null`. + */ +typealias BridgeMethodCallback = (args: BridgeMethodArgs) -> Any? + +typealias BridgeMethodArgs = ArrayList + +/** + * 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/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a025b9..a9a427b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - KettuXposed - \ No newline at end of file + ShiggyXposed + 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