diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1fc257c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,53 @@ +name: Build APK +on: [push] + +jobs: + build: + name: Build and push + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-java@v3 + with: + distribution: "temurin" + java-version: "17" + + - name: "Setup Gradle" + uses: gradle/gradle-build-action@v2 + + - name: "Grant execution permission" + run: chmod +x ./gradlew + + - name: "Build APKs" + run: ./gradlew build + + - name: "Setup directories" + run: | + mkdir -p /tmp/pyonbuild/apks + mkdir -p /tmp/pyonbuild/tools + + - name: "Move debug APK" + run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/pyonbuild/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/pyonbuild/tools/uber-apk-signer.jar + + - name: "Sign release APK" + run: java -jar /tmp/pyonbuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/pyonbuild/apks/ + + - name: "Rename release APK" + run: mv /tmp/pyonbuild/apks/app-release-aligned-debugSigned.apk /tmp/pyonbuild/apks/app-release.apk + + - name: "Upload debug APK" + uses: actions/upload-artifact@v3 + with: + name: app-debug + path: /tmp/pyonbuild/apks/app-debug.apk + + - name: "Upload release APK" + uses: actions/upload-artifact@v3 + with: + name: app-release + path: /tmp/pyonbuild/apks/app-release.apk \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e8efbde --- /dev/null +++ b/LICENSE @@ -0,0 +1,172 @@ +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of +authorship (the "Original Work") whose owner (the "Licensor") has placed the +following licensing notice adjacent to the copyright notice for the Original +Work: + +Licensed under the Open Software License version 3.0 + +1) Grant of Copyright License. Licensor grants You a worldwide, royalty-free, +non-exclusive, sublicensable license, for the duration of the copyright, to do +the following: + + a) to reproduce the Original Work in copies, either alone or as part of a + collective work; + + b) to translate, adapt, alter, transform, modify, or arrange the Original + Work, thereby creating derivative works ("Derivative Works") based upon the + Original Work; + + c) to distribute or communicate copies of the Original Work and Derivative + Works to the public, with the proviso that copies of Original Work or + Derivative Works that You distribute or communicate shall be licensed under + this Open Software License; + + d) to perform the Original Work publicly; and + + e) to display the Original Work publicly. + +2) Grant of Patent License. Licensor grants You a worldwide, royalty-free, +non-exclusive, sublicensable license, under patent claims owned or controlled +by the Licensor that are embodied in the Original Work as furnished by the +Licensor, for the duration of the patents, to make, use, sell, offer for sale, +have made, and import the Original Work and Derivative Works. + +3) Grant of Source Code License. The term "Source Code" means the preferred +form of the Original Work for making modifications to it and all available +documentation describing how to modify the Original Work. Licensor agrees to +provide a machine-readable copy of the Source Code of the Original Work along +with each copy of the Original Work that Licensor distributes. Licensor +reserves the right to satisfy this obligation by placing a machine-readable +copy of the Source Code in an information repository reasonably calculated to +permit inexpensive and convenient access by You for as long as Licensor +continues to distribute the Original Work. + +4) Exclusions From License Grant. Neither the names of Licensor, nor the names +of any contributors to the Original Work, nor any of their trademarks or +service marks, may be used to endorse or promote products derived from this +Original Work without express prior permission of the Licensor. Except as +expressly stated herein, nothing in this License grants any license to +Licensor's trademarks, copyrights, patents, trade secrets or any other +intellectual property. No patent license is granted to make, use, sell, offer +for sale, have made, or import embodiments of any patent claims other than the +licensed claims defined in Section 2. No license is granted to the trademarks +of Licensor even if such marks are included in the Original Work. Nothing in +this License shall be interpreted to prohibit Licensor from licensing under +terms different from this License any Original Work that Licensor otherwise +would have a right to license. + +5) External Deployment. The term "External Deployment" means the use, +distribution, or communication of the Original Work or Derivative Works in any +way such that the Original Work or Derivative Works may be used by anyone +other than You, whether those works are distributed or communicated to those +persons or made available as an application intended for use over a network. +As an express condition for the grants of license hereunder, You must treat +any External Deployment by You of the Original Work or a Derivative Work as a +distribution under section 1(c). + +6) Attribution Rights. You must retain, in the Source Code of any Derivative +Works that You create, all copyright, patent, or trademark notices from the +Source Code of the Original Work, as well as any notices of licensing and any +descriptive text identified therein as an "Attribution Notice." You must cause +the Source Code for any Derivative Works that You create to carry a prominent +Attribution Notice reasonably calculated to inform recipients that You have +modified the Original Work. + +7) Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that +the copyright in and to the Original Work and the patent rights granted herein +by Licensor are owned by the Licensor or are sublicensed to You under the +terms of this License with the permission of the contributor(s) of those +copyrights and patent rights. Except as expressly stated in the immediately +preceding sentence, the Original Work is provided under this License on an "AS +IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without +limitation, the warranties of non-infringement, merchantability or fitness for +a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK +IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this +License. No license to the Original Work is granted by this License except +under this disclaimer. + +8) Limitation of Liability. Under no circumstances and under no legal theory, +whether in tort (including negligence), contract, or otherwise, shall the +Licensor be liable to anyone for any indirect, special, incidental, or +consequential damages of any character arising as a result of this License or +the use of the Original Work including, without limitation, damages for loss +of goodwill, work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses. This limitation of liability shall not +apply to the extent applicable law prohibits such limitation. + +9) Acceptance and Termination. If, at any time, You expressly assented to this +License, that assent indicates your clear and irrevocable acceptance of this +License and all of its terms and conditions. If You distribute or communicate +copies of the Original Work or a Derivative Work, You must make a reasonable +effort under the circumstances to obtain the express assent of recipients to +the terms of this License. This License conditions your rights to undertake +the activities listed in Section 1, including your right to create Derivative +Works based upon the Original Work, and doing so without honoring these terms +and conditions is prohibited by copyright law and international treaty. +Nothing in this License is intended to affect copyright exceptions and +limitations (including "fair use" or "fair dealing"). This License shall +terminate immediately and You may no longer exercise any of the rights granted +to You by this License upon your failure to honor the conditions in Section +1(c). + +10) Termination for Patent Action. This License shall terminate automatically +and You may no longer exercise any of the rights granted to You by this +License as of the date You commence an action, including a cross-claim or +counterclaim, against Licensor or any licensee alleging that the Original Work +infringes a patent. This termination provision shall not apply for an action +alleging patent infringement by combinations of the Original Work with other +software or hardware. + +11) Jurisdiction, Venue and Governing Law. Any action or suit relating to this +License may be brought only in the courts of a jurisdiction wherein the +Licensor resides or in which Licensor conducts its primary business, and under +the laws of that jurisdiction excluding its conflict-of-law provisions. The +application of the United Nations Convention on Contracts for the +International Sale of Goods is expressly excluded. Any use of the Original +Work outside the scope of this License or after its termination shall be +subject to the requirements and penalties of copyright or patent law in the +appropriate jurisdiction. This section shall survive the termination of this +License. + +12) Attorneys' Fees. In any action to enforce the terms of this License or +seeking damages relating thereto, the prevailing party shall be entitled to +recover its costs and expenses, including, without limitation, reasonable +attorneys' fees and costs incurred in connection with such action, including +any appeal of such action. This section shall survive the termination of this +License. + +13) Miscellaneous. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent necessary +to make it enforceable. + +14) Definition of "You" in This License. "You" throughout this License, +whether in upper or lower case, means an individual or a legal entity +exercising rights under, and complying with all of the terms of, this License. +For legal entities, "You" includes any entity that controls, is controlled by, +or is under common control with you. For purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the direction or +management of such entity, whether by contract or otherwise, or (ii) ownership +of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial +ownership of such entity. + +15) Right to Use. You may use the Original Work in all ways not otherwise +restricted or conditioned by this License or by law, and Licensor promises not +to interfere with or be responsible for such uses by You. + +16) Modification of This License. This License is Copyright © 2005 Lawrence +Rosen. Permission is granted to copy, distribute, or communicate this License +without modification. Nothing in this License permits You to modify this +License as applied to the Original Work or to Derivative Works. However, You +may modify the text of this License and copy, distribute or communicate your +modified version (the "Modified License") and apply it to other original works +of authorship subject to the following conditions: (i) You may not indicate in +any way that your Modified License is the "Open Software License" or "OSL" and +you may not use those names in the name of your Modified License; (ii) You +must replace the notice specified in the first paragraph above with the notice +"Licensed under " or with a notice of your own +that is not confusingly similar to the notice in this License; and (iii) You +may not claim that your original works are open source software unless your +Modified License has been approved by Open Source Initiative (OSI) and You +comply with its license review and certification process. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4a334f..972489d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ android { minSdk = 24 targetSdk = 33 versionCode = 1 - versionName = "1.0.0-rewrite" + versionName = "0.1.0" } buildTypes { @@ -37,6 +37,7 @@ android { dependencies { compileOnly("de.robv.android.xposed:api:82") + implementation("androidx.core:core:1.10.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9") implementation("io.ktor:ktor-client-core:2.3.0") diff --git a/app/src/main/assets/js/preinit.js b/app/src/main/assets/js/preinit.js deleted file mode 100644 index 15d61a0..0000000 --- a/app/src/main/assets/js/preinit.js +++ /dev/null @@ -1,13 +0,0 @@ -let originalObjectCreate = Object.create - -Object.create = function create(proto) { - let ret = originalObjectCreate.apply(this, arguments) - if (proto === null) { - Object.create = originalObjectCreate - return modules = ret - } - - return ret -} - -Object.freeze = Object diff --git a/app/src/main/kotlin/com/pyoncord/xposed/Main.kt b/app/src/main/kotlin/com/pyoncord/xposed/Main.kt index ede6bbe..a51298e 100644 --- a/app/src/main/kotlin/com/pyoncord/xposed/Main.kt +++ b/app/src/main/kotlin/com/pyoncord/xposed/Main.kt @@ -1,191 +1,182 @@ package com.pyoncord.xposed -import android.app.Activity -import android.content.Intent -import android.content.Context import android.content.res.AssetManager import android.content.res.Resources -import android.content.res.XModuleResources -import android.graphics.drawable.Drawable -import android.graphics.Typeface -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.Environment import android.util.Log -import android.annotation.SuppressLint -import android.graphics.fonts.Font import de.robv.android.xposed.IXposedHookLoadPackage -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 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.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 -import java.lang.Exception -import java.lang.System -import java.util.HashMap -class Main : IXposedHookZygoteInit, IXposedHookLoadPackage { - companion object { - const val LOG_TAG = "Pyoncord" - const val DEFAULT_BUNDLE_ENDPOINT = "https://raw.githubusercontent.com/pyoncord/pyoncord/builds/pyoncord.js" +@Serializable +data class CustomLoadUrl( + val enabled: Boolean, + val url: String +) +@Serializable +data class LoaderConfig( + val customLoadUrl: CustomLoadUrl, + val loadReactDevTools: Boolean? = null, + val loadPyoncord: Boolean? = null +) - val PYONCORD_DIR = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), "pyoncord").apply { mkdirs() } +class Main : IXposedHookLoadPackage { + private val pyonModules: Array = arrayOf( + ThemeModule(), + SysColorsModule() + ) - val CACHE_DIR = File(PYONCORD_DIR, "cache").apply { mkdir() } - val DRAWABLES_DIR = File(PYONCORD_DIR, "drawables").apply { mkdir() } - - val bundle = File(PYONCORD_DIR, "pyoncord.js") - val etag = File(PYONCORD_DIR, "etag") + fun buildLoaderJsonString(): String { + val obj = buildJsonObject { + put("loaderName", "PyoncordXposed") - val loaderPayloadJson = buildJsonObject { - put("loader", "PyoncordXposed") - } - } - - private lateinit var resources: XModuleResources - private lateinit var appActivity: Activity - - // Assign module resources in process zygote - override fun initZygote(startupParam: IXposedHookZygoteInit.StartupParam) { - resources = XModuleResources.createInstance(startupParam.modulePath, null) - } - - override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) = with(lpparam) { - val reactActivity = runCatching { - classLoader.loadClass("com.discord.react_activities.ReactActivity") - }.getOrElse { return@with } // Package is our the target app, return - - XposedBridge.hookMethod(reactActivity.getDeclaredMethod("onCreate", Bundle::class.java), object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - appActivity = param.thisObject as Activity - - init(lpparam) + for (module in pyonModules) { + module.buildJson(this) } - }) - } - - fun createEvalFile(code: String, identifier: String) = File(CACHE_DIR, "$identifier.js").apply { writeText(code) } - - fun restartApp() = with(appActivity.applicationContext) { - val intent = packageManager.getLaunchIntentForPackage(packageName)!! - - appActivity.startActivity(Intent.makeRestartActivityTask(intent.component)) - System.exit(0) - } - - suspend fun checkForUpdate(): Unit = withContext(Dispatchers.IO) { - val client = HttpClient(CIO) { - install(HttpTimeout) { requestTimeoutMillis = 1000 } - install(UserAgent) { agent = "PyoncordXposed" } } - try { - val response: HttpResponse = client.get(DEFAULT_BUNDLE_ENDPOINT) { - headers { - if (etag.exists() && bundle.exists()) { - append(HttpHeaders.IfNoneMatch, etag.readText()) + return Json.encodeToString(obj) + } + + override fun handleLoadPackage(param: XC_LoadPackage.LoadPackageParam) = with (param) { + val catalystInstanceImpl = runCatching { + classLoader.loadClass("com.facebook.react.bridge.CatalystInstanceImpl") + }.getOrElse { return@with } + + for (module in pyonModules) module.onInit(param) + + val loadScriptFromAssets = catalystInstanceImpl.getDeclaredMethod( + "jniLoadScriptFromAssets", + AssetManager::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ).apply { isAccessible = true } + + val loadScriptFromFile = catalystInstanceImpl.getDeclaredMethod( + "jniLoadScriptFromFile", + 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() + Json { ignoreUnknownKeys = true }.decodeFromString(configFile.readText()) + } catch (_: Exception) { + LoaderConfig( + customLoadUrl = CustomLoadUrl( + enabled = false, + url = "http://localhost:4040/bunny.js" + ), + loadReactDevTools = false, + loadPyoncord = false + ) + } + + val scope = MainScope() + val httpJob = scope.async(Dispatchers.IO) { + try { + val client = HttpClient(CIO) { + install(HttpTimeout) { requestTimeoutMillis = 1000 } + install(UserAgent) { agent = "PyonXposed" } + } + + val url = + if (config.customLoadUrl.enabled) config.customLoadUrl.url + else if (config.loadPyoncord == true) "https://raw.githubusercontent.com/pyoncord/pyoncord/builds/pyoncord.js" + else "https://raw.githubusercontent.com/pyoncord/detta-builds/main/bunny.js" + + val response: HttpResponse = client.get(url) { + headers { + if (etag.exists() && bundle.exists()) { + append(HttpHeaders.IfNoneMatch, etag.readText()) + } } } + + if (response.status == HttpStatusCode.OK) { + bundle.writeBytes(response.body()) + response.headers["Etag"]?.let { etag.writeText(it) } + } + + return@async + } catch (e: Exception) { + Log.e("Pyoncord", "Failed to download Pyoncord") } - - if (response.status == HttpStatusCode.OK) { - Log.i(LOG_TAG, "New bundle found, updating and restarting...") - - bundle.writeBytes(response.body()) - response.headers["Etag"]?.let { etag.writeText(it) } - restartApp() - } - } catch (e: Exception) { - Log.e(LOG_TAG, "An error occured while downloading pyoncord bundle.", e) - } finally { - client.close() - } - } - - @SuppressLint("NewApi") - fun init(param: XC_LoadPackage.LoadPackageParam) = with(param) { - val catalystInstance = classLoader.loadClass("com.facebook.react.bridge.CatalystInstanceImpl") - - MainScope().launch() { - checkForUpdate() } - runCatching { - // Fight package renaming side effects - 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(param: MethodHookParam) { - if (param.args[2] == packageName) param.args[2] = "com.discord" + 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]) + ) } + } - // Custom drawables - val uriCache = mutableMapOf() - val resourceDrawableIdHelper = classLoader.loadClass("com.facebook.react.views.imagehelper.ResourceDrawableIdHelper") - - XposedBridge.hookMethod(resourceDrawableIdHelper.getDeclaredMethod("getResourceDrawableUri", Context::class.java, String::class.java), object: XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - val name = param.args[1] as? String ?: return + XposedBridge.hookMethod(loadScriptFromAssets, patch) + XposedBridge.hookMethod(loadScriptFromFile, patch) - val uri = uriCache.getOrPut(name) { - File(DRAWABLES_DIR, "$name.png").takeIf { it.exists() }?.let { - Uri.fromFile(it) - } ?: Uri.EMPTY - } + // 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 + ) - if (uri != Uri.EMPTY) param.result = uri + XposedBridge.hookMethod(getIdentifier, object: XC_MethodHook() { + override fun beforeHookedMethod(mhparam: MethodHookParam) = with(mhparam) { + if (args[2] == param.packageName) args[2] = "com.discord" } }) } - - val loadScriptFromAssets = catalystInstance.getDeclaredMethod("jniLoadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType) - val loadScriptFromFile = catalystInstance.getDeclaredMethod("jniLoadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType) - - // TODO: Loader config - val hook = object: XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) = with(param) { - val payloadFile = createEvalFile("PYONCORD_LOADER_PAYLOAD=${Json.encodeToString(loaderPayloadJson)}", "payload") - - // Load pre-patches and payload - XposedBridge.invokeOriginalMethod(loadScriptFromAssets, thisObject, arrayOf(resources.assets, "assets://js/preinit.js", true)) - XposedBridge.invokeOriginalMethod( - loadScriptFromFile, - thisObject, - arrayOf(payloadFile.absolutePath, "pyoncord-payload", true) - ) - - // Invoke the original method - XposedBridge.invokeOriginalMethod(method, thisObject, args) - - // Execute our bundle - if (bundle.exists()) XposedBridge.invokeOriginalMethod( - loadScriptFromFile, - thisObject, - arrayOf(bundle.absolutePath, bundle.absolutePath, args[2]) - ) - - // Prevent from invoking the original method - setResult(null) - } - } - - XposedBridge.hookMethod(loadScriptFromAssets, hook) - XposedBridge.hookMethod(loadScriptFromFile, hook) - - return@with } } \ No newline at end of file diff --git a/app/src/main/kotlin/com/pyoncord/xposed/PyonModule.kt b/app/src/main/kotlin/com/pyoncord/xposed/PyonModule.kt new file mode 100644 index 0000000..e4bd4a4 --- /dev/null +++ b/app/src/main/kotlin/com/pyoncord/xposed/PyonModule.kt @@ -0,0 +1,9 @@ +package com.pyoncord.xposed + +import de.robv.android.xposed.callbacks.XC_LoadPackage +import kotlinx.serialization.json.JsonObjectBuilder + +abstract class PyonModule { + 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/com/pyoncord/xposed/SysColorsModule.kt b/app/src/main/kotlin/com/pyoncord/xposed/SysColorsModule.kt new file mode 100644 index 0000000..4e0992c --- /dev/null +++ b/app/src/main/kotlin/com/pyoncord/xposed/SysColorsModule.kt @@ -0,0 +1,50 @@ +package com.pyoncord.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 : PyonModule() { + private lateinit var context: Context + 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)) + } + } + } + } + } + + 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/com/pyoncord/xposed/ThemeModule.kt b/app/src/main/kotlin/com/pyoncord/xposed/ThemeModule.kt new file mode 100644 index 0000000..0d97ae5 --- /dev/null +++ b/app/src/main/kotlin/com/pyoncord/xposed/ThemeModule.kt @@ -0,0 +1,162 @@ +package com.pyoncord.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 : PyonModule() { + 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() + } + + fun File.isValidish(): Boolean { + if (!this.exists()) return false + + val text = this.readText() + return !text.isBlank() && text != "{}" && text != "null" + } + + 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 + + val themeText = themeFile.readText() + + return try { + 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.DarkTheme") + 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) + } + } + + // Convert 0xRRGGBBAA to 0XAARRGGBB + fun hexStringToColorInt(hexString: String): Int { + val parsed = Color.parseColor(hexString) + return parsed.takeIf { hexString.length == 7 } ?: parsed and 0xFFFFFF or (parsed ushr 24) + } + + 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