diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/CacheModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/CacheModule.kt new file mode 100644 index 0000000..01b4971 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/CacheModule.kt @@ -0,0 +1,270 @@ +package ShiggyXposed.xposed.modules + +import android.content.Context +import android.util.AtomicFile +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.bridge.BridgeModule +import java.io.* + +/** + * A module for caching versioned modules finds and blacklist, and assets mappings. + * + * See [ModulesCache] and [AssetsCache] for the cache structure. + * + * ## Methods + * + * ### Modules cache + * + * - `Shiggy.caches.modules.read() : { blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }, version: number } | null` + * - Reads the modules cache. Returns `null` if the cache file does not exist or is invalid. + * + * - `Shiggy.caches.modules.write(blacklist: number[], finds: { [filter: string]: { [id: string]: number } | null }) : void` + * - Writes the modules cache. + * + * ### Assets cache + * + * - `Shiggy.caches.assets.read() : { data: { [name: string]: { [type: string]: number } }, version: number } | null` + * - Reads the assets cache. Returns `null` if the cache file does not exist or is invalid. + * + * - `Shiggy.caches.assets.write(data: { [name: string]: { [type: string]: number } }) : void` + * - Writes the assets cache. + * + * The caches are versioned and tied to the app version code. When the app is updated, + * the caches are invalidated and new cache files are created. + */ +object CacheModule : Module() { + + private const val CACHE_DIR = "revenge" + + private const val MODULES_CACHE_PREFIX = "modules" + private const val ASSETS_CACHE_PREFIX = "assets" + + + private lateinit var modulesCache: ModulesCache + private lateinit var assetsCache: AssetsCache + + override fun onContext(context: Context) = with(context) { + val revengeCacheDir = File(cacheDir, CACHE_DIR).apply { asDir() } + val (_, _, _, versionCode) = getAppInfo() + + val modulesCacheFile = File( + revengeCacheDir, "$MODULES_CACHE_PREFIX.$versionCode" + ).apply { asFile() } + + val assetsCacheFile = File( + revengeCacheDir, "$ASSETS_CACHE_PREFIX.$versionCode" + ).apply { asFile() } + + BridgeModule.registerMethod("revenge.caches.modules.read") { + if (::modulesCache.isInitialized) modulesCache.toMap() else ModulesCache.loadFromFileOrNull(modulesCacheFile) + ?.let { + modulesCache = it + it.toMap() + } + } + + BridgeModule.registerMethod("revenge.caches.modules.write") { + val (blacklist, finds) = it + @Suppress("UNCHECKED_CAST") + modulesCache = ModulesCache( + blacklist as ArrayList, finds as HashMap?> + ).apply { saveToFile(modulesCacheFile) } + Log.i("Modules cache saved: ${modulesCacheFile.absolutePath} (blacklisted: ${blacklist.size}, finds: ${finds.size})") + } + + BridgeModule.registerMethod("revenge.caches.assets.read") { + if (::assetsCache.isInitialized) assetsCache.toMap() else AssetsCache.loadFromFileOrNull(assetsCacheFile) + ?.let { + assetsCache = it + it.toMap() + } + } + + BridgeModule.registerMethod("revenge.caches.assets.write") { + val (data) = it + @Suppress("UNCHECKED_CAST") + assetsCache = + AssetsCache(data as HashMap>).apply { saveToFile(assetsCacheFile) } + Log.i("Assets cache saved: ${assetsCacheFile.absolutePath} (count: ${data.size})") + } + } +} + +private class CacheVersionMismatchException(expected: Int, actual: Int) : + Throwable("Expected cache version: $expected, but got version: $actual") + +data class ModulesCache( + val blacklist: List, val finds: Map?> +) { + fun saveToFile(file: File) { + val atomic = AtomicFile(file) + var fos: FileOutputStream? = null + try { + fos = atomic.startWrite() + val out = DataOutputStream(BufferedOutputStream(fos)) + + out.writeInt(VERSION) + + // Write blacklist + out.writeInt(blacklist.size) + for (num in blacklist) out.writeInt(num.toInt()) + + // Write finds + out.writeInt(finds.size) + for ((filter, matches) in finds) { + out.writeUTF(filter) + if (matches == null) { + out.writeBoolean(false) // null marker + } else { + out.writeBoolean(true) // not null + out.writeInt(matches.size) + for ((id, flag) in matches) { + out.writeInt(id.toInt()) + out.writeInt(flag.toInt()) + } + } + } + + out.flush() + atomic.finishWrite(fos) + } catch (t: Throwable) { + if (fos != null) atomic.failWrite(fos) + throw t + } + } + + fun toMap(): Map { + val findsMap = finds.mapValues { (_, matches) -> matches?.map { (k, v) -> k to v }?.toMap() } + + return mapOf("blacklist" to blacklist, "finds" to findsMap, "version" to VERSION) + } + + companion object { + const val VERSION = 3 + + fun loadFromFileOrNull(file: File): ModulesCache? { + if (!file.exists() || file.length() <= 0L) return null + val atomic = AtomicFile(file) + try { + DataInputStream(BufferedInputStream(atomic.openRead())).use { input -> + val version = input.readInt() + if (version != VERSION) throw CacheVersionMismatchException(VERSION, version) + + val blacklistSize = input.readInt() + val blacklist = MutableList(blacklistSize) { input.readInt().toDouble() } + + val findsSize = input.readInt() + val finds = mutableMapOf?>() + repeat(findsSize) { + val filter = input.readUTF() + val hasMatches = input.readBoolean() + if (!hasMatches) { + finds[filter] = null + } else { + val mapSize = input.readInt() + val matches = mutableMapOf() + repeat(mapSize) { + val id = input.readInt().toString() + val flag = input.readInt().toDouble() + matches[id] = flag + } + finds[filter] = matches + } + } + + return ModulesCache(blacklist, finds) + } + } catch (e: CacheVersionMismatchException) { + Log.i("Modules cache version mismatch: ${e.message}") + } catch (e: EOFException) { + Log.e("Modules cache corrupt: ${e.message}") + } catch (e: IOException) { + Log.e("Failed to read modules cache: ${e.message}") + } + runCatching { file.delete() } + return null + } + } +} + +data class AssetsCache( + val data: Map> +) { + fun saveToFile(file: File) { + val atomic = AtomicFile(file) + var fos: FileOutputStream? = null + try { + fos = atomic.startWrite() + val out = DataOutputStream(BufferedOutputStream(fos)) + + out.writeInt(VERSION) + + out.writeInt(data.size) + for ((name, mappings) in data) { + out.writeUTF(name) + out.writeInt(mappings.size) + for ((type, id) in mappings) { + out.writeUTF(type) + out.writeInt(id.toInt()) + } + } + + out.flush() + atomic.finishWrite(fos) + } catch (t: Throwable) { + if (fos != null) atomic.failWrite(fos) + throw t + } + } + + fun toMap(): Map { + return mapOf( + "data" to data.mapValues { (_, mappings) -> + mappings.map { (type, id) -> type to id }.toMap() + }, "version" to VERSION + ) + } + + companion object { + const val VERSION = 2 + + fun loadFromFileOrNull(file: File): AssetsCache? { + if (!file.exists() || file.length() <= 0L) return null + val atomic = AtomicFile(file) + try { + DataInputStream(BufferedInputStream(atomic.openRead())).use { input -> + val version = input.readInt() + if (version != VERSION) throw CacheVersionMismatchException(VERSION, version) + + val dataSize = input.readInt() + val data = mutableMapOf>() + + repeat(dataSize) { + val name = input.readUTF() + val mappingsSize = input.readInt() + val mappings = mutableMapOf() + + repeat(mappingsSize) { + val type = input.readUTF() + val id = input.readInt().toDouble() + mappings[type] = id + } + + data[name] = mappings + } + + return AssetsCache(data) + } + } catch (e: CacheVersionMismatchException) { + Log.i("Assets cache version mismatch: ${e.message}") + } catch (e: EOFException) { + Log.e("Assets cache corrupt: ${e.message}") + } catch (e: IOException) { + Log.e("Failed to read assets cache: ${e.message}") + } + runCatching { file.delete() } + return null + } + } +} 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 index 53ef733..15cfbf5 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/FixResourcesModule.kt @@ -1,21 +1,23 @@ -package GoonXposed.xposed.modules +package ShiggyXposed.xposed.modules -import GoonXposed.xposed.Constants.Companion.TARGET_PACKAGE -import GoonXposed.xposed.Module import android.content.res.Resources import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Constants.Companion.TARGET_PACKAGE +import ShiggyXposed.xposed.Module -/** Hooks [Resources.getIdentifier] to fix resource package name mismatch. */ -class FixResourcesModule : Module() { - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = - with(packageParam) { - if (packageName != TARGET_PACKAGE) { - Resources::class.java.hookMethod( - "getIdentifier", - String::class.java, - String::class.java, - String::class.java - ) { before { if (args[2] == packageName) args[2] = TARGET_PACKAGE } } +/** + * Hooks [Resources.getIdentifier] to fix resource package name mismatch. + */ +object 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 index 76df819..0a0dd84 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/HookScriptLoaderModule.kt @@ -1,76 +1,65 @@ -package GoonXposed.xposed.modules +package ShiggyXposed.xposed.modules -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Log -import GoonXposed.xposed.modules.HookScriptLoaderModule.Companion.PRELOADS_DIR import android.content.res.AssetManager import android.content.res.XModuleResources import de.robv.android.xposed.IXposedHookZygoteInit import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.HookStateHolder +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking import java.io.File import java.lang.reflect.Method -import kotlinx.coroutines.runBlocking /** * Hooks React Native's script loading methods to load custom scripts and bundles. * - * Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files - * directory. + * Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files directory. * - * The main bundle should be placed in the [Constants.CACHE_DIR] directory named - * [Constants.MAIN_SCRIPT_FILE]. If the bundle file does not exist, it will attempt to load - * `assets://revenge.bundle` from the module's assets. + * The main bundle should be placed in the [Constants.CACHE_DIR] directory named [Constants.MAIN_SCRIPT_FILE]. + * If the bundle file does not exist, it will attempt to load `assets://Shiggy.bundle` from the module's assets. */ -class HookScriptLoaderModule : Module() { +object 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" - } + 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() } + 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() } + 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) } - } + listOf( + "com.facebook.react.runtime.ReactInstance\$loadJSBundle$1", + "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 - ) + private fun hook(instance: Class<*>) = runCatching { + 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 - ) + val loadScriptFromFile = instance.method( + "loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType + ) loadScriptFromAssets.hook { before { @@ -85,45 +74,44 @@ class HookScriptLoaderModule : Module() { runCustomScripts(loadScriptFromFile, loadScriptFromAssets) } } + }.onFailure { + Log.e("Failed to hook script loading methods in ${instance.name}:", it) } - private fun HookScope.runCustomScripts( - loadScriptFromFile: Method, - loadScriptFromAssets: Method - ) { + private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) { Log.i("Running custom scripts...") - // TODO: Is there a better way to do this? - runBlocking { UpdaterModule.job?.join() } + runBlocking { + val ready = async { HookStateHolder.readyDeferred.join() } + val download = async { UpdaterModule.downloadScript().join() } + + awaitAll(ready, download) + } val loadSynchronously = args[2] val runScriptFile = { file: File -> Log.i("Loading script: ${file.absolutePath}") XposedBridge.invokeOriginalMethod( - loadScriptFromFile, - thisObject, - arrayOf(file.absolutePath, file.absolutePath, loadSynchronously) + 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) + if (!::resources.isInitialized) resources = XModuleResources.createInstance(modulePath, null) XposedBridge.invokeOriginalMethod( - loadScriptFromAssets, - thisObject, - arrayOf(resources.assets, "assets://bundle.js", loadSynchronously) + loadScriptFromAssets, + thisObject, + arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously) ) } } catch (e: Throwable) { 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 index 35e0ae2..351e7c8 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/LogBoxModule.kt @@ -1,4 +1,4 @@ -package GoonXposed.xposed.modules +package ShiggyXposed.xposed.modules import android.app.AlertDialog import android.content.Context @@ -8,10 +8,10 @@ import android.os.Build import android.widget.EditText import android.widget.Toast import de.robv.android.xposed.callbacks.XC_LoadPackage -import GoonXposed.xposed.BuildConfig -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils -import GoonXposed.xposed.Utils.Log +import ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils +import ShiggyXposed.xposed.Utils.Log import de.robv.android.xposed.XposedHelpers import kotlinx.coroutines.* import org.json.JSONArray @@ -19,9 +19,9 @@ import org.json.JSONObject import java.io.File import java.net.HttpURLConnection import java.net.URL -import GoonXposed.xposed.Utils.Companion.reloadApp +import ShiggyXposed.xposed.Utils.Companion.reloadApp -class LogBoxModule : Module() { +object LogBoxModule : Module() { lateinit var packageParam: XC_LoadPackage.LoadPackageParam private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -143,7 +143,7 @@ class LogBoxModule : Module() { } val dialog = AlertDialog.Builder(context) - .setTitle("GoonXposed Recovery Menu") + .setTitle("ShiggyXposed Recovery Menu") .setAdapter(adapter) { _, which -> handleMenuSelection(context, which) } @@ -260,7 +260,7 @@ class LogBoxModule : Module() { } val urlInput = EditText(context).apply { - hint = "http://localhost:4040/gooncord.js" + hint = "http://localhost:4040/shiggycord.js" setTextColor(0xFFFFFFFF.toInt()) setBackgroundColor(0xFF2D2D2D.toInt()) setHintTextColor(0xFF888888.toInt()) @@ -375,7 +375,7 @@ class LogBoxModule : Module() { val config = JSONObject(configFile.readText()) val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() customLoadUrl.put("enabled", false) - customLoadUrl.put("url", "http://localhost:4040/gooncord.js") + customLoadUrl.put("url", "http://localhost:4040/shiggycord.js") config.put("customLoadUrl", customLoadUrl) configFile.writeText(config.toString()) } @@ -407,7 +407,7 @@ class LogBoxModule : Module() { val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() customLoadUrl.put("enabled", false) - customLoadUrl.put("url", "http://localhost:4040/gooncord.js") + customLoadUrl.put("url", "http://localhost:4040/shiggycord.js") config.put("customLoadUrl", customLoadUrl) configFile.writeText(config.toString()) @@ -503,7 +503,7 @@ class LogBoxModule : Module() { if (configFile.exists()) { val config = JSONObject(configFile.readText()) config.put("customLoadUrlEnabled", false) - config.put("customLoadUrl", "http://localhost:4040/GoonCord.js") + config.put("customLoadUrl", "http://localhost:4040/ShiggyCord.js") configFile.writeText(config.toString()) } 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 index fc0dd3d..956cfc2 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/PayloadGlobalModule.kt @@ -4,11 +4,11 @@ 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 GoonXposed.xposed.BuildConfig -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Companion.JSON -import GoonXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR +import ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.io.File diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/PluginsModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/PluginsModule.kt new file mode 100644 index 0000000..8da4acd --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/PluginsModule.kt @@ -0,0 +1,124 @@ +package ShiggyXposed.xposed.modules + +import android.content.Context +import android.util.AtomicFile +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.bridge.BridgeModule +import java.io.* + +/** + * Manages the states of plugins, allowing reading and writing of plugin states to a file. + * + * ## Methods + * + * - `Shiggy.plugins.states.read(): { flags: { [pluginId: string]: number } }` + * - Reads the current plugin states from the file and returns them as a map. + * + * - `Shiggy.plugins.states.write(flags: { [pluginId: string]: number }): void` + * - Writes the provided plugin states to the file. + */ +class PluginsModule : Module() { + private companion object { + const val DATA_DIR = "Shiggy/plugins" + const val STATES_FILE = "states" + } + + private lateinit var states: PluginStates + + override fun onContext(context: Context) = with(context) { + val dataDir = File(filesDir, DATA_DIR) + + val statesFile = File( + dataDir, STATES_FILE + ).apply { asFile() } + + BridgeModule.registerMethod("Shiggy.plugins.states.read") { + if (::states.isInitialized) states.toMap() else + PluginStates.loadFromFileOrNull(statesFile)?.let { + states = it + it.toMap() + } + } + + BridgeModule.registerMethod("Shiggy.plugins.states.write") { + val (flags) = it + @Suppress("UNCHECKED_CAST") + states = PluginStates( + flags as Map + ).apply { saveToFile(statesFile) } + Log.i("Plugin states saved: ${statesFile.absolutePath}") + } + } +} + +private class UnsupportedPluginStatesVersionException(version: Int) : + Throwable("Unsupported plugin states version: $version") + +data class PluginStates( + val flags: Map +) { + fun saveToFile(file: File) { + val atomic = AtomicFile(file) + var fos: FileOutputStream? = null + try { + fos = atomic.startWrite() + val out = DataOutputStream(BufferedOutputStream(fos)) + + out.writeInt(CURRENT_VERSION) + + // Write flags + out.writeInt(flags.size) + for ((id, flags) in flags) { + out.writeUTF(id) + out.writeInt(flags.toInt()) + } + + out.flush() + atomic.finishWrite(fos) + } catch (t: Throwable) { + if (fos != null) atomic.failWrite(fos) + throw t + } + } + + fun toMap(): Map = mapOf("flags" to flags) + + companion object { + const val CURRENT_VERSION = 1 + + fun loadFromFileOrNull(file: File): PluginStates? { + if (!file.exists() || file.length() <= 0L) return null + val atomic = AtomicFile(file) + + try { + DataInputStream(BufferedInputStream(atomic.openRead())).use { input -> + when (val version = input.readInt()) { + 1 -> return loadV1FromFileOrNull(input) + else -> throw UnsupportedPluginStatesVersionException(version) + } + } + } catch (e: UnsupportedPluginStatesVersionException) { + Log.i(e.message!!) + } catch (e: EOFException) { + Log.e("Plugin states corrupt: ${e.message}") + } catch (e: IOException) { + Log.e("Failed to read plugin states: ${e.message}") + } + + runCatching { file.delete() } + return null + } + + private fun loadV1FromFileOrNull(input: DataInputStream): PluginStates { + val flagsSize = input.readInt() + val flags = mutableMapOf() + + repeat(flagsSize) { + flags[input.readUTF()] = input.readInt().toDouble() + } + + return PluginStates(flags) + } + } +} 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 index 21cac1b..eb4f668 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/UpdaterModule.kt @@ -1,13 +1,18 @@ -package GoonXposed.xposed.modules +package ShiggyXposed.xposed.modules -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Companion.JSON -import GoonXposed.xposed.Utils.Log import android.app.Activity +import android.app.AlertDialog +import android.content.Context import android.util.AtomicFile +import android.widget.Toast import androidx.core.util.writeBytes import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.Utils.Companion.reloadApp +import ShiggyXposed.xposed.Utils.Log import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* @@ -15,134 +20,133 @@ 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 +import java.io.File +import java.lang.ref.WeakReference -@Serializable data class CustomLoadUrl(val enabled: Boolean = false, val url: String = "") +@Serializable +data class CustomLoadUrl( + val enabled: Boolean = false, val url: String = "" +) -@Serializable data class LoaderConfig(val customLoadUrl: CustomLoadUrl = CustomLoadUrl()) +@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() { +object UpdaterModule : Module() { private lateinit var config: LoaderConfig private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var error: Throwable? = null + private var lastActivity: WeakReference? = 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 = 10000L + private const val ETAG_FILE = "etag.txt" + private const val CONFIG_FILE = "loader.json" - private const val TIMEOUT_CACHED = 5000L - private const val TIMEOUT = 50000L - private const val ETAG_FILE = "etag.txt" - private const val CONFIG_FILE = "loader.json" + private const val DEFAULT_BUNDLE_URL = + "https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/shiggycord.js" - private const val DEFAULT_BUNDLE_URL = - "https://github.com/OTKUSteyler/GoonCord/releases/download/1.1.5/goooncord.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()) } - 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() } + 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") - 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) {} + val response: HttpResponse = client.get(url) { + headers { + if (etag.exists() && bundle.exists()) { + append(HttpHeaders.IfNoneMatch, etag.readText()) } - .use { client -> - val url = - config.customLoadUrl.takeIf { it.enabled }?.url - ?: DEFAULT_BUNDLE_URL - Log.i("Fetching JS bundle from: $url") + } - val response: HttpResponse = - client.get(url) { - headers { - if (etag.exists() && bundle.exists()) { - append( - HttpHeaders.IfNoneMatch, - etag.readText() - ) - } - } - - // Retries don't need timeout - if (activity != null) { - var timeoutMs = TIMEOUT_CACHED - if (!bundle.exists()) { - timeoutMs = TIMEOUT - } - timeout { requestTimeoutMillis = timeoutMs } - } - } - - when (response.status) { - HttpStatusCode.OK -> { - val bytes: ByteArray = response.body() - AtomicFile(bundle).writeBytes(bytes) - - val newTag = response.headers[HttpHeaders.ETag] - if (!newTag.isNullOrEmpty()) etag.writeText(newTag) - else etag.delete() - - Log.i("Bundle updated (${bytes.size} bytes)") - - // This is a retry, so we show a dialog - if (activity != null) { - withContext(Dispatchers.Main) {} - } - } - HttpStatusCode.NotModified -> { - Log.i("Server responded with 304, no changes") - } - else -> { - throw ResponseException( - response, - "Received status: ${response.status}" - ) - } - } - } - } catch (e: Throwable) { - Log.e("Failed to download script", e) - error = e + // Retries don't need timeout + if (activity == null) { + timeout { + requestTimeoutMillis = if (!bundle.exists()) TIMEOUT else TIMEOUT_CACHED + } } } + + when (response.status) { + HttpStatusCode.OK -> { + val bytes: ByteArray = response.body() + AtomicFile(bundle).writeBytes(bytes) + + val newTag = response.headers[HttpHeaders.ETag] + if (!newTag.isNullOrEmpty()) etag.writeText(newTag) else etag.delete() + + Log.i("Bundle updated (${bytes.size} bytes)") + + // This is a retry, so we show a dialog + if (activity != null) { + withContext(Dispatchers.Main) { + AlertDialog.Builder(activity).setTitle("ShiggyCord Update Successful") + .setMessage("A reload is required for changes to take effect.") + .setPositiveButton("Reload") { dialog, _ -> + reloadApp() + dialog.dismiss() + }.setCancelable(false).show() + } + } + } + + HttpStatusCode.NotModified -> { + Log.i("Server responded with 304, no changes") + } + + else -> { + throw ResponseException(response, "Received status: ${response.status}") + } + } + } + } catch (e: Throwable) { + Log.e("Failed to download script", e) + showErrorDialog(e) + } } override fun onActivity(activity: Activity) { - error ?: return - error = null - downloadScript(activity) + lastActivity = WeakReference(activity) + } + + fun showErrorDialog(e: Throwable) { + val activity = lastActivity?.get() ?: return + } + + fun resetLoaderConfig(context: Context) { + val filesDir = File(context.dataDir, Constants.FILES_DIR) + val config = File(filesDir, CONFIG_FILE) + if (config.exists()) config.delete() } } 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 index 5c50cbe..ec6111e 100644 --- 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 @@ -1,9 +1,5 @@ -package GoonXposed.xposed.modules.appearance +package ShiggyXposed.xposed.modules.appearance -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Companion.JSON -import GoonXposed.xposed.Utils.Log import android.content.res.AssetManager import android.graphics.Typeface import android.graphics.Typeface.CustomFallbackBuilder @@ -13,6 +9,10 @@ import android.os.Build import de.robv.android.xposed.XC_MethodReplacement import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.Utils.Log import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* @@ -20,128 +20,106 @@ 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 +import java.io.File +import java.io.IOException @Serializable data class FontDefinition( - val name: String? = null, - val description: String? = null, - val spec: Int? = null, - val main: Map, + 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/" - } +object FontsModule : Module() { + private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") + private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") + private 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) } + 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() - } - } + 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) } + }) - // 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 fontDefFile = File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json").apply { asFile() } + if (!fontDefFile.exists()) return@with - val client = - HttpClient(CIO) { - install(UserAgent) { - agent = Constants.USER_AGENT - } - } + val fontDef = try { + JSON.decodeFromString(fontDefFile.readText()) + } catch (_: Throwable) { + return@with + } - val response: HttpResponse = client.get(url) - if (response.status == HttpStatusCode.OK) - file.writeBytes(response.body()) + fontsDownloadsDir = File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { asDir() } + fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { asDir() } + fontsAbsPath = fontsDir.absolutePath + "/" - return@async - } catch (e: Throwable) { - Log.e("Failed to download fonts ($name from $url)", e) - } - } - } - .awaitAll() + 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 + fontFamilyNames: Array, style: Int, assetManager: AssetManager ): Typeface? { val fontFamilies: MutableList = ArrayList() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -151,27 +129,22 @@ class FontsModule : Module() { try { for (fileExtension in FILE_EXTENSIONS) { val (customName, refName) = fontFamilyName.split(":") - val file = - File(fontsDownloadsDir, "$customName/$refName.$fileExtension") - .apply { asFile() } + 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) {} + } 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() + StringBuilder().append(fontRootPath).append(fontFamilyName).append(fileExtension).toString() try { - val builder = - if (fileName[0] == '/') Font.Builder(File(fileName)) - else Font.Builder(assetManager, fileName) + val builder = if (fileName[0] == '/') Font.Builder(File(fileName)) else Font.Builder( + assetManager, fileName + ) val font = builder.build() val family = FontFamily.Builder(font).build() fontFamilies.add(family) @@ -187,8 +160,7 @@ class FontsModule : Module() { } // If there's some problem constructing fonts, fall back to the default behavior. - if (fontFamilies.isEmpty()) - return createAssetTypeface(fontFamilyNames[0], style, assetManager) + if (fontFamilies.isEmpty()) return createAssetTypeface(fontFamilyNames[0], style, assetManager) val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0]) for (i in 1 until fontFamilies.size) { @@ -200,34 +172,25 @@ class FontsModule : Module() { } private fun createAssetTypeface( - fontFamilyName: String, - style: Int, - assetManager: AssetManager + fontFamilyName: String, style: Int, assetManager: AssetManager ): Typeface? { // This logic attempts to safely check if the frontend code is attempting to use // fallback fonts, and if it is, to use the fallback typeface creation logic. var fontFamilyName: String = fontFamilyName - val fontFamilyNames = - fontFamilyName.split(",".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + 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 + // 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] - } + fontFamilyName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return createAssetTypefaceWithFallbacks(fontFamilyNames, style, assetManager) + } else { + fontFamilyNames[0] + } } val extension = EXTENSIONS[style] @@ -235,26 +198,20 @@ class FontsModule : Module() { try { for (fileExtension in FILE_EXTENSIONS) { val (customName, refName) = fontFamilyName.split(":") - val file = - File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { - asFile() - } + val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension").apply { asFile() } if (!file.exists()) throw Exception() return Typeface.createFromFile(file.absolutePath) } - } catch (_: Throwable) {} + } 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() + StringBuilder().append(fontRootPath).append(fontFamilyName).append(extension).append(fileExtension) + .toString() return try { if (fileName[0] == '/') Typeface.createFromFile(fileName) 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 index 2e70400..31833e1 100644 --- 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 @@ -1,45 +1,43 @@ -package GoonXposed.xposed.modules.appearance +package ShiggyXposed.xposed.modules.appearance -import GoonXposed.xposed.Module import android.R.color import android.app.AndroidAppHelper import android.content.Context import android.os.Build import androidx.core.content.ContextCompat +import ShiggyXposed.xposed.Module import kotlinx.serialization.json.* +import java.lang.ref.WeakReference -class SysColorsModule : Module() { - private lateinit var context: Context +object SysColorsModule : Module() { + private lateinit var context: WeakReference private fun isSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S override fun buildPayload(builder: JsonObjectBuilder) { - context = AndroidAppHelper.currentApplication() + context = WeakReference(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 + 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 } + val colorResourceId = runCatching { + color::class.java.getField(colorName).getInt(null) + }.getOrElse { 0 } - add(convertToColor(colorResourceId)) - } - } + add(convertToColor(colorResourceId)) } + } + } } } private fun convertToColor(id: Int): String { - val clr = if (isSupported()) ContextCompat.getColor(context, id) else 0 + val clr = if (isSupported()) ContextCompat.getColor(context.get()!!, 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 index eed4a79..4c55133 100644 --- 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 @@ -1,42 +1,47 @@ -package GoonXposed.xposed.module.appearance +package ShiggyXposed.xposed.modules.appearance -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Companion.JSON import android.content.Context import android.content.res.Resources import androidx.core.graphics.toColorInt import de.robv.android.xposed.callbacks.XC_LoadPackage -import java.io.File +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put +import java.io.File -@Serializable data class Author(val name: String, val id: String? = null) +@Serializable +data class Author( + val name: String, val id: String? = null +) @Serializable data class ThemeData( - val name: String, - val description: String? = null, - val authors: List? = null, - val spec: Int, - val semanticColors: Map>? = null, - val rawColors: Map? = null + 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) +@Serializable +data class Theme( + val id: String, val selected: Boolean, val data: ThemeData +) -class ThemesModule : Module() { +object 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" - } + private const val THEME_FILE = "current-theme.json" + @ExperimentalSerializationApi override fun buildPayload(builder: JsonObjectBuilder) { @@ -48,9 +53,7 @@ class ThemesModule : Module() { } private fun String.fromScreamingSnakeToCamelCase() = - this.split("_").joinToString("") { it -> - it.lowercase().replaceFirstChar { it.uppercase() } - } + this.split("_").joinToString("") { it -> it.lowercase().replaceFirstChar { it.uppercase() } } override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) { param = packageParam @@ -58,7 +61,7 @@ class ThemesModule : Module() { hookTheme() } - private fun File.isValidish(): Boolean { + private fun File.isValidThemeFile(): Boolean { if (!this.exists()) return false val text = this.readText() @@ -66,11 +69,8 @@ class ThemesModule : Module() { } private fun getTheme(): Theme? { - val themeFile = - File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { - asFile() - } - if (!themeFile.isValidish()) return null + val themeFile = File(param.appInfo.dataDir, "${Constants.FILES_DIR}/${THEME_FILE}").apply { asFile() } + if (!themeFile.isValidThemeFile()) return null return try { val themeText = themeFile.readText() @@ -106,34 +106,28 @@ class ThemesModule : Module() { // 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 getColorCompat = themeManager.getDeclaredMethod( + "getColorCompat", + Resources::class.java, + Int::class.javaPrimitiveType, + Resources.Theme::class.java, + ) - val getColorCompatLegacy = - themeManager.getDeclaredMethod( - "getColorCompat", - Context::class.java, - Int::class.javaPrimitiveType - ) + val getColorCompatLegacy = themeManager.getDeclaredMethod( + "getColorCompat", Context::class.java, Int::class.javaPrimitiveType + ) - val patch = - MethodHookBuilder().run { - 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) + 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] - } + if (rawColorMap[name] != null) result = rawColorMap[name] + } - build() - } + build() + } getColorCompat.hook(patch) getColorCompatLegacy.hook(patch) @@ -153,8 +147,13 @@ class ThemesModule : Module() { private fun hookThemeMethod(themeClass: Class<*>, methodName: String, themeValue: Int) { try { themeClass.getDeclaredMethod(methodName).let { method -> - method.hook { before { result = themeValue } } + method.hook { + before { + result = themeValue + } + } } - } catch (_: NoSuchMethodException) {} + } catch (_: NoSuchMethodException) { + } } } 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 index 8e9e014..6d21887 100644 --- 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 @@ -1,52 +1,94 @@ -package GoonXposed.xposed.modules.bridge +package ShiggyXposed.xposed.modules.bridge -import GoonXposed.xposed.Module +import android.app.Activity import android.app.AlertDialog import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.os.Build import android.widget.Toast +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils +import java.io.File -class AdditionalBridgeMethodsModule : Module() { - override fun onContext(context: Context) = - with(context) { - BridgeModule.registerMethod("revenge.alertError") { - val (error, version) = it - val app = getAppInfo() - val errorString = "$error" +object AdditionalBridgeMethodsModule : Module() { + override fun onContext(context: Context) = with(context) { + BridgeModule.registerMethod("Shiggy.fs.getConstants") { + mapOf( + "data" to dataDir.absolutePath, + "files" to filesDir.absolutePath, + "cache" to cacheDir.absolutePath, + ) + } - val clipboard = - applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as - ClipboardManager - val clip = ClipData.newPlainText("Stack Trace", errorString) + BridgeModule.registerMethod("Shiggy.fs.delete") { + val (path) = it + File(path as String).run { + if (this.isDirectory) this.deleteRecursively() + else this.delete() + } + } - AlertDialog.Builder(this) - .setTitle("GoonXposed Error") - .setMessage( - """ - GoonXposed: $version + BridgeModule.registerMethod("Shiggy.fs.exists") { + val (path) = it + File(path as String).exists() + } + + BridgeModule.registerMethod("Shiggy.fs.read") { it -> + val (path) = it + val file = File(path as String).apply { openFileGuarded() } + + file.bufferedReader().use { it.readText() } + } + + BridgeModule.registerMethod("Shiggy.fs.write") { + val (path, contents) = it + val file = File(path as String).apply { openFileGuarded() } + + file.writeText(contents as String) + } + } + + override fun onActivity(activity: Activity) = with(activity) { + BridgeModule.registerMethod("Shiggy.alertError") { + val (error, version) = it + val app = getAppInfo() + val errorString = "$error" + + val clipboard = applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Stack Trace", errorString) + + AlertDialog.Builder(this) + .setTitle("ShiggyCord Error") + .setMessage( + """ + ShiggyCord: $version ${app.name}: ${app.version} (${app.versionCode}) Device: ${Build.MANUFACTURER} ${Build.MODEL} - """.trimIndent() + - errorString - ) - .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } - .setNeutralButton("Copy") { dialog, _ -> - clipboard.setPrimaryClip(clip) - Toast.makeText( - applicationContext, - "Copied stack trace", - Toast.LENGTH_SHORT - ) - .show() - dialog.dismiss() - } - .show() - - null + """.trimIndent() + errorString + ) + .setPositiveButton("OK") { dialog, _ -> + dialog.dismiss() } - } + .setNeutralButton("Copy") { dialog, _ -> + clipboard.setPrimaryClip(clip) + Toast.makeText(applicationContext, "Copied stack trace", Toast.LENGTH_SHORT).show() + dialog.dismiss() + } + .show() + + null + } + + BridgeModule.registerMethod("Shiggy.showRecoveryAlert") { + Utils.showRecoveryAlert(this) + } + } + + private fun File.openFileGuarded() { + if (!this.exists()) throw Error("Path does not exist: $path") + if (!this.isFile) throw Error("Path is not a file: $path") + } } 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 index 41fd824..d5808aa 100644 --- 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 @@ -1,11 +1,10 @@ -package GoonXposed.xposed.modules.bridge +package ShiggyXposed.xposed.modules.bridge -import GoonXposed.xposed.BuildConfig -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Log import de.robv.android.xposed.callbacks.XC_LoadPackage -import java.io.File +import ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log import java.lang.reflect.Method /** @@ -24,15 +23,14 @@ typealias BridgeMethodArgs = ArrayList * To call a method, pass an object with the following structure to a hooked method: * ```js * { - * revenge: { + * Shiggy: { * method: "method.name", * args: [arg1, arg2, ...] * } * } * ``` * - * For methods that accept additional arguments, only the first object argument is used for the - * bridge call. + * 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 @@ -47,153 +45,104 @@ typealias BridgeMethodArgs = ArrayList * } * ``` */ -class BridgeModule : Module() { +object BridgeModule : Module() { private lateinit var readableMapGetString: Method - private lateinit var readableMapToHashMapMethod: 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 const val CALL_DATA_KEY = "Shiggy" + 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() - 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 - } + /** + * 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") + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val arguments = classLoader.loadClass("com.facebook.react.bridge.Arguments") + val readableMap = classLoader.loadClass("com.facebook.react.bridge.ReadableMap") + val promise = classLoader.loadClass("com.facebook.react.bridge.Promise") - val promiseResolve = promise.method("resolve", Object::class.java) - argumentsMakeNative = arguments.method("makeNativeObject", Object::class.java) - readableMapGetString = readableMap.method("getString", String::class.java) - readableMapToHashMapMethod = readableMap.method("toHashMap") + 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 - ) { + classLoader.loadClass("com.horcrux.svg.RNSVGRenderableManager") + .hookMethod("getBBox", Double::class.javaObjectType, readableMap) { before { - callBridgeMethod(readableMapToHashMapConverter(args[1]!!))?.let { - result = it.toNativeObject() - } + callBridgeMethod(readableMapToHashMap(args[1]!!))?.let { result = it.toNativeObject() } } } - classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule").hookMethod( - "readAsDataURL", - readableMap, - promise - ) { + classLoader.loadClass("com.facebook.react.modules.blob.FileReaderModule") + .hookMethod("readAsDataURL", readableMap, promise) { before { - // Avoid shadowing the outer `readableMap` and `promise` variables - val (readableMapArg, promiseArg) = args - callBridgeMethod(readableMapToHashMapConverter(readableMapArg!!))?.let { - promiseResolve.invoke(promiseArg, it.toNativeObject()) + val (readableMap, promise) = args + callBridgeMethod(readableMapToHashMap(readableMap!!))?.let { + promiseResolve.invoke(promise, it.toNativeObject()) result = null } } } - } + + // Clear methods every package load. Plugins should re-register on each load. + methods.clear() + registerDefaultMethods() + + return@with } - private fun Any?.toNativeObject(): Any? = - argumentsMakeNative.invoke( - null, - when (this) { - Unit -> null - else -> this - } + private fun registerDefaultMethods() { + methods["Shiggy.info"] = { + mapOf( + "name" to Constants.LOADER_NAME, "version" to BuildConfig.VERSION_CODE ) + } - private fun readableMapToHashMapConverter(map: Any): HashMap { - @Suppress("UNCHECKED_CAST") - return readableMapToHashMapMethod.invoke(map) as HashMap - } - - private fun callBridgeMethod(hashMap: HashMap): Map? { - try { - val pair = getBridgeCallData(hashMap) ?: return null - val (method, args) = pair - val ret = method(args).toNativeObject() - return mapOf("result" to ret) - } catch (e: Throwable) { - return mapOf("error" to e.toString()) + methods["Shiggy.test"] = { + 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, + ) } } - private fun getBridgeCallData( - hashMap: HashMap - ): Pair? { + 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.stackTraceToString()) + } + + private fun getBridgeCallData(hashMap: HashMap): Pair? { @Suppress("UNCHECKED_CAST") val data = hashMap[CALL_DATA_KEY] as HashMap? data ?: return null diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockCrashReportingModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockCrashReportingModule.kt new file mode 100644 index 0000000..a808a86 --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockCrashReportingModule.kt @@ -0,0 +1,46 @@ +package ShiggyXposed.xposed.modules.no_track + +import android.content.Context +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log + +/** + * Hooks Discord's crash reporting to disable Sentry initialization. + * While Discord doesn't have Sentry auto-initialization on by default, we still hook the content provider just to be safe. + */ +object BlockCrashReportingModule : Module() { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val crashReportingClass = classLoader.safeLoadClass("com.discord.crash_reporting.CrashReporting") + crashReportingClass?.apply { + // Hooking this will result in a crash after a few seconds, since Discord expects initialization to complete when setting a Sentry tag. + // So we also hook isDisabled to make sure those calls are no-ops. + hookMethod( + "init", Context::class.java, String::class.java + ) { + before { + Log.i("Blocked CrashReporting initialization") + result = null + } + } + runCatching { + hookMethod("isDisabled") { + before { + Log.i("Forced CrashReporting.isDisabled() to true") + result = true + } + } + } + + val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider") + sentryInitProviderClass?.hookMethod("onCreate") { + before { + Log.i("Blocked SentryInitProvider initialization") + result = true + } + } + + return@with + } + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt new file mode 100644 index 0000000..454028f --- /dev/null +++ b/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt @@ -0,0 +1,25 @@ +package ShiggyXposed.xposed.modules.no_track + +import android.content.Context +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log + +/** + * Hooks Discord's deep links tracking to disable AppsFlyer initialization. + */ +object BlockDeepLinksTrackingModule : Module() { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val deepLinksClass = classLoader.safeLoadClass("com.discord.deep_link.DeepLinks") + deepLinksClass?.hookMethod( + "init", Context::class.java + ) { + before { + Log.i("Blocked DeepLinks tracking initialization") + result = null + } + } + + return@with + } +}