feat(shake menu): add more options

This commit is contained in:
cocobo1 2025-11-18 17:46:58 +02:00
parent c9ecf551bf
commit 0eb28c094d
4 changed files with 75 additions and 215 deletions

View file

@ -5,9 +5,6 @@
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
</SelectionState> </SelectionState>
<SelectionState runConfigName="KettuXposed.app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates> </selectionStates>
</component> </component>
</project> </project>

1
.idea/misc.xml generated
View file

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration"> <component name="FrameworkDetectionExcludesConfiguration">

View file

@ -44,7 +44,7 @@ class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit {
FixResourcesModule, FixResourcesModule,
BlockDeepLinksTrackingModule, BlockDeepLinksTrackingModule,
BlockCrashReportingModule, BlockCrashReportingModule,
LogBoxModule(), LogBoxModule,
CacheModule, CacheModule,
FontsModule, FontsModule,
ThemesModule, ThemesModule,

View file

@ -1,70 +1,82 @@
package io.github.revenge.xposed.modules package io.github.revenge.xposed.modules
import android.content.Context
import de.robv.android.xposed.callbacks.XC_LoadPackage
import cocobo1.pupu.xposed.BuildConfig
import io.github.revenge.xposed.Module
import io.github.revenge.xposed.Utils
import io.github.revenge.xposed.Utils.Companion.reloadApp
import io.github.revenge.xposed.Utils.Log
import android.app.AlertDialog import android.app.AlertDialog
import android.content.Intent import android.content.Context
import android.net.Uri
import android.os.Build
import android.widget.EditText
import android.widget.Toast import android.widget.Toast
import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import io.github.revenge.xposed.Module
import io.github.revenge.xposed.Utils.Companion.reloadApp
import io.github.revenge.xposed.Utils.Log
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
class LogBoxModule : Module() { object LogBoxModule : Module() {
lateinit var packageParam: XC_LoadPackage.LoadPackageParam lateinit var packageParam: XC_LoadPackage.LoadPackageParam
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) {
this@LogBoxModule.packageParam = packageParam this@LogBoxModule.packageParam = packageParam
val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") try {
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" } val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost")
val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" }
getUseDeveloperSupportMethod.hook {
before { getUseDeveloperSupportMethod.hook {
result = true before {
result = true
}
} }
} catch (e: Exception) {
Log.e("Failed to hook DCDReactNativeHost: ${e.message}")
} }
listOf( listOf(
"com.facebook.react.devsupport.BridgeDevSupportManager", "com.facebook.react.devsupport.BridgeDevSupportManager",
"com.facebook.react.devsupport.BridgelessDevSupportManager" "com.facebook.react.devsupport.BridgelessDevSupportManager"
).mapNotNull { packageParam.classLoader.safeLoadClass(it) }.forEach { ).mapNotNull { packageParam.classLoader.safeLoadClass(it) }.forEach {
hookDevSupportManager(it) hookDevSupportManager(it)
} }
return@with return@with
} }
override fun onContext(context: Context) {
}
private fun hookDevSupportManager(clazz: Class<*>) { private fun hookDevSupportManager(clazz: Class<*>) {
val showDevOptionsDialogMethod = clazz.methods.first { it.name == "showDevOptionsDialog" } try {
val handleReloadJSMethod = clazz.methods.first { it.name == "handleReloadJS" }
showDevOptionsDialogMethod.hook { handleReloadJSMethod.hook {
before { before {
try { reloadApp()
val context = getContextFromDevSupport(clazz, thisObject)
if (context != null) {
showRecoveryMenu(context)
}
result = null result = null
} catch (e: Exception) {
Log.e("Failed to show recovery menu: ${e.message}")
} }
} }
val showDevOptionsDialogMethod = clazz.methods.first { it.name == "showDevOptionsDialog" }
showDevOptionsDialogMethod.hook {
before {
try {
val context = getContextFromDevSupport(clazz, thisObject)
if (context != null) {
showRecoveryMenu(context)
}
result = null // Ignore the original dev menu
} catch (e: Exception) {
Log.e("Failed to show recovery menu: ${e.message}")
}
}
}
} catch (e: Exception) {
Log.e("Failed to hook DevSupportManager methods on class ${clazz.name}: ${e.message}")
} }
} }
private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? { private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? {
return try { return try {
val mReactInstanceDevHelperField = XposedHelpers.findField(clazz, "mReactInstanceDevHelper") val mReactInstanceDevHelperField = XposedHelpers.findField(clazz, "mReactInstanceDevHelper")
@ -76,44 +88,14 @@ class LogBoxModule : Module() {
null null
} }
} }
private fun reloadReactNative(context: Context) {
try {
val reactInstanceManagerClass = packageParam.classLoader.loadClass("com.facebook.react.ReactInstanceManager")
val reactApplicationClass = packageParam.classLoader.loadClass("com.facebook.react.ReactApplication")
val app = context.applicationContext
if (reactApplicationClass.isInstance(app)) {
val getReactNativeHostMethod = reactApplicationClass.methods.first { it.name == "getReactNativeHost" }
val reactNativeHost = getReactNativeHostMethod.invoke(app)
val getReactInstanceManagerMethod = reactNativeHost.javaClass.methods.first { it.name == "getReactInstanceManager" }
val reactInstanceManager = getReactInstanceManagerMethod.invoke(reactNativeHost)
if (reactInstanceManager != null) {
val reloadMethod = reactInstanceManagerClass.methods.first { it.name == "recreateReactContextInBackground" }
reloadMethod.invoke(reactInstanceManager)
} else {
showError(context, "Error", "Could not get React Instance Manager")
}
} else {
showError(context, "Error", "Application is not a React Application")
}
} catch (e: Exception) {
Log.e("Failed to Reload App: ${e.message}")
showError(context, "Failed to reload", "Error: ${e.message}")
}
}
private fun showRecoveryMenu(context: Context) { private fun showRecoveryMenu(context: Context) {
val options = arrayOf( val options = arrayOf(
if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode", if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode",
"Refetch Bundle", "Reset Bundle",
"Load Custom Bundle", "Reload App (Full Relaunch)"
"Reload App"
) )
AlertDialog.Builder(context) AlertDialog.Builder(context)
.setTitle("KettuXposed Recovery Menu") .setTitle("KettuXposed Recovery Menu")
.setItems(options) { _, which -> .setItems(options) { _, which ->
@ -122,16 +104,15 @@ class LogBoxModule : Module() {
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
.show() .show()
} }
private fun handleMenuSelection(context: Context, index: Int) { private fun handleMenuSelection(context: Context, index: Int) {
when (index) { when (index) {
0 -> toggleSafeMode(context) 0 -> toggleSafeMode(context)
1 -> confirmAction(context, "refetch the bundle") { refetchBundle(context) } 1 -> confirmAction(context, "reset bundle") { resetBundle(context) }
2 -> loadCustomBundle(context) 2 -> reloadApp()
3 -> reloadApp()
} }
} }
private fun confirmAction(context: Context, actionText: String, action: () -> Unit) { private fun confirmAction(context: Context, actionText: String, action: () -> Unit) {
AlertDialog.Builder(context) AlertDialog.Builder(context)
.setTitle("Confirm Action") .setTitle("Confirm Action")
@ -140,12 +121,12 @@ class LogBoxModule : Module() {
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
.show() .show()
} }
private fun isSafeModeEnabled(context: Context): Boolean { private fun isSafeModeEnabled(context: Context): Boolean {
return try { return try {
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS") val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
if (!settingsFile.exists()) return false if (!settingsFile.exists()) return false
val json = JSONObject(settingsFile.readText()) val json = JSONObject(settingsFile.readText())
json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false
} catch (e: Exception) { } catch (e: Exception) {
@ -153,26 +134,26 @@ class LogBoxModule : Module() {
false false
} }
} }
private fun toggleSafeMode(context: Context) { private fun toggleSafeMode(context: Context) {
try { try {
val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS") val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS")
val themeFile = File(context.filesDir, "vd_mmkv/VENDETTA_THEMES") val themeFile = File(context.filesDir, "vd_mmkv/VENDETTA_THEMES")
settingsFile.parentFile?.mkdirs() settingsFile.parentFile?.mkdirs()
val settings = if (settingsFile.exists()) { val settings = if (settingsFile.exists()) {
JSONObject(settingsFile.readText()) JSONObject(settingsFile.readText())
} else { } else {
JSONObject() JSONObject()
} }
val safeMode = settings.optJSONObject("safeMode") ?: JSONObject() val safeMode = settings.optJSONObject("safeMode") ?: JSONObject()
val currentState = safeMode.optBoolean("enabled", false) val currentState = safeMode.optBoolean("enabled", false)
val newState = !currentState val newState = !currentState
safeMode.put("enabled", newState) safeMode.put("enabled", newState)
if (newState && themeFile.exists()) { if (newState && themeFile.exists()) {
val theme = JSONObject(themeFile.readText()) val theme = JSONObject(themeFile.readText())
val themeId = theme.optString("id") val themeId = theme.optString("id")
@ -181,12 +162,11 @@ class LogBoxModule : Module() {
themeFile.delete() themeFile.delete()
} }
} }
settings.put("safeMode", safeMode) settings.put("safeMode", safeMode)
settingsFile.writeText(settings.toString()) settingsFile.writeText(settings.toString())
Toast.makeText(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}", Toast.LENGTH_SHORT).show()
reloadApp() reloadApp()
} catch (e: Exception) { } catch (e: Exception) {
@ -194,40 +174,20 @@ class LogBoxModule : Module() {
showError(context, "Failed to toggle safe mode", e.message) showError(context, "Failed to toggle safe mode", e.message)
} }
} }
private fun refetchBundle(context: Context) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js")
val backupFile = File(pyoncordDir, "bundle.js.backup")
if (bundleFile.exists()) {
backupFile.delete()
bundleFile.renameTo(backupFile)
Log.e("Bundle moved to backup")
}
reloadApp()
} catch (e: Exception) {
Log.e("Error refetching bundle: ${e.message}")
showError(context, "Failed to refetch bundle", e.message)
}
}
private fun resetBundle(context: Context) { private fun resetBundle(context: Context) {
try { try {
val pyoncordDir = getPyoncordDirectory(context) val pyoncordDir = getPyoncordDirectory(context)
val bundleFile = File(pyoncordDir, "bundle.js") val bundleFile = File(pyoncordDir, "bundle.js")
val backupFile = File(pyoncordDir, "bundle.js.backup")
val configFile = File(pyoncordDir, "loader_config.json") val configFile = File(pyoncordDir, "loader_config.json")
bundleFile.delete() bundleFile.delete()
backupFile.delete()
// Reset config
if (configFile.exists()) { if (configFile.exists()) {
val config = JSONObject(configFile.readText()) val config = JSONObject(configFile.readText())
config.put("customLoadUrlEnabled", false) config.put("customLoadUrlEnabled", false)
config.put("customLoadUrl", "http://localhost:4040/kettu.js")
configFile.writeText(config.toString()) configFile.writeText(config.toString())
} }
@ -237,100 +197,7 @@ class LogBoxModule : Module() {
showError(context, "Failed to reset bundle", e.message) showError(context, "Failed to reset bundle", e.message)
} }
} }
private fun loadCustomBundle(context: Context) {
val input = EditText(context).apply {
hint = "http://localhost:4040/kettu.js"
inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI
}
AlertDialog.Builder(context)
.setTitle("Load Custom Bundle")
.setMessage("Enter the URL for your custom bundle:")
.setView(input)
.setPositiveButton("Load") { _, _ ->
val urlString = input.text.toString().trim()
if (urlString.isEmpty()) {
showError(context, "Invalid URL", "Please enter a URL")
return@setPositiveButton
}
validateAndLoadBundle(context, urlString)
}
.setNegativeButton("Cancel", null)
.show()
}
private fun validateAndLoadBundle(context: Context, urlString: String) {
scope.launch(Dispatchers.IO) {
try {
val url = URL(urlString)
if (url.protocol != "http" && url.protocol != "https") {
withContext(Dispatchers.Main) {
showError(context, "Invalid URL", "URL must start with http:// or https://")
}
return@launch
}
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10000
connection.readTimeout = 10000
val responseCode = connection.responseCode
if (responseCode != 200) {
withContext(Dispatchers.Main) {
showError(context, "Error", "Server returned error $responseCode")
}
return@launch
}
val contentType = connection.contentType
if (contentType != null && !contentType.contains("javascript") && !contentType.contains("text")) {
withContext(Dispatchers.Main) {
showError(context, "Invalid Content", "URL must point to a JavaScript file")
}
return@launch
}
// Save custom bundle URL
withContext(Dispatchers.Main) {
setCustomBundleURL(context, urlString)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showError(context, "Connection Error", "Could not reach URL: ${e.message}")
}
}
}
}
private fun setCustomBundleURL(context: Context, url: String) {
try {
val pyoncordDir = getPyoncordDirectory(context)
val configFile = File(pyoncordDir, "loader_config.json")
val config = if (configFile.exists()) {
JSONObject(configFile.readText())
} else {
JSONObject()
}
config.put("customLoadUrlEnabled", true)
config.put("customLoadUrl", url)
configFile.writeText(config.toString())
File(pyoncordDir, "bundle.js").delete()
Toast.makeText(context, "Custom bundle set", Toast.LENGTH_SHORT).show()
reloadApp()
} catch (e: Exception) {
Log.e("Error setting custom bundle URL: ${e.message}")
showError(context, "Failed to save configuration", e.message)
}
}
private fun getPyoncordDirectory(context: Context): File { private fun getPyoncordDirectory(context: Context): File {
val dir = File(context.filesDir, "pyoncord") val dir = File(context.filesDir, "pyoncord")
if (!dir.exists()) { if (!dir.exists()) {
@ -338,7 +205,7 @@ class LogBoxModule : Module() {
} }
return dir return dir
} }
private fun showError(context: Context, title: String, message: String?) { private fun showError(context: Context, title: String, message: String?) {
AlertDialog.Builder(context) AlertDialog.Builder(context)
.setTitle(title) .setTitle(title)
@ -346,7 +213,4 @@ class LogBoxModule : Module() {
.setPositiveButton("OK", null) .setPositiveButton("OK", null)
.show() .show()
} }
override fun onContext(context: Context) {
}
} }