Add safe mode enforcement and redesign recovery dialog

Safe mode now loads only preloads with '00_' prefix or legacy sentinel.
Recovery/dev dialog is replaced with a dark-themed UI featuring safe
mode toggle, custom URL loader, and improved confirmation modals.
This commit is contained in:
kmmiio99o 2025-10-12 20:17:51 +02:00
parent 2619c31c68
commit 97e69dcc42
2 changed files with 497 additions and 24 deletions

View file

@ -110,7 +110,30 @@ class HookScriptLoaderModule : Module() {
}
try {
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
// Safe mode enforcement:
// If a preload file with a '00_' prefix exists (or legacy 'rv_safe_mode.js'), treat
// this as a safe-mode indicator and load only those safe preloads (skip other
// preloads), but still load the main bundle afterwards.
val safeFiles =
preloadsDir
.listFiles()
?.filter {
it.isFile &&
(it.name.startsWith("00_") || it.name == "rv_safe_mode.js")
}
?.sortedBy { it.name }
if (!safeFiles.isNullOrEmpty()) {
Log.i(
"Safe mode detected - loading only safe preloads: ${safeFiles.map { it.name }}"
)
// Load only safe preloads; do not load other preloads. Continue and load main
// bundle.
safeFiles.forEach(runScriptFile)
} else {
// Normal behavior: run all preloads
preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile)
}
if (mainScript.exists()) runScriptFile(mainScript)
else {

View file

@ -2,16 +2,36 @@ package ShiggyXposed.xposed.modules
import ShiggyXposed.xposed.Constants
import ShiggyXposed.xposed.Module
import ShiggyXposed.xposed.Utils
import ShiggyXposed.xposed.Utils.Companion.reloadApp
import ShiggyXposed.xposed.Utils.Log
import android.app.AlertDialog
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Switch
import android.widget.TextView
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage
import java.io.File
import kotlinx.serialization.json.*
/**
* Replacement for the old recovery/dev dialog. Shows a dark-themed dialog on the main thread with:
* - Safe Mode toggle (writes/removes preloads/00_rv_safe_mode.js)
* - Load from custom URL toggle + URL input (persists to files/pyoncord/loader.json)
* - Save & Reload, Delete bundle, Close actions
*
* Safe Mode toggle now behaves like the React snippet: toggles immediately (no reload) and opens a
* confirmation modal asking whether to reload now or later. The confirmation modal uses a custom
* in-dialog layout so button colors and text can be controlled for dark styling.
*/
class LogBoxModule : Module() {
lateinit var packageParam: XC_LoadPackage.LoadPackageParam
lateinit var bridgeDevSupportManagerClass: Class<*>
@ -46,7 +66,7 @@ class LogBoxModule : Module() {
"mReactInstanceDevHelper"
)
// This enables the LogBox and opens dev option on shake
// Enable developer support (so shake/dev menu becomes available)
XposedBridge.hookMethod(
getUseDeveloperSupportMethod,
object : XC_MethodHook() {
@ -56,8 +76,7 @@ class LogBoxModule : Module() {
}
)
// Replace the method to direct relaunch the app instead of sending reload
// command to developer server
// Replace reload with app restart to avoid remote dev server dependency
XposedBridge.hookMethod(
handleReloadJSMethod,
object : XC_MethodHook() {
@ -68,7 +87,7 @@ class LogBoxModule : Module() {
}
)
// Triggered on shake - Moved from onContext to onLoad
// Intercept showDevOptionsDialog and replace with our dialog
XposedBridge.hookMethod(
showDevOptionsDialogMethod,
object : XC_MethodHook() {
@ -88,41 +107,472 @@ class LogBoxModule : Module() {
showRecoveryAlert(context)
} catch (ex: Exception) {
Log.e("Failed to show dev options dialog: $ex")
Log.e(
"Failed to show dev options dialog: ${ex.message}",
ex
)
XposedBridge.log(
"ShiggyXposed: Failed to show dev options dialog: ${android.util.Log.getStackTraceString(ex)}"
)
}
// Ignore the original dev menu
// Prevent original menu from showing
param.result = null
}
}
)
} catch (e: Exception) {
Log.e("Error in LogBoxModule.onLoad: $e")
Log.e("Error in LogBoxModule.onLoad: ${e.message}", e)
XposedBridge.log(
"ShiggyXposed: LogBoxModule.onLoad error: ${android.util.Log.getStackTraceString(e)}"
)
}
return@with
}
private fun showRecoveryAlert(context: Context) {
AlertDialog.Builder(context)
.setTitle("ShiggyXposed Recovery Options")
.setItems(arrayOf("Reload", "Delete bundle.js")) { _, which ->
when (which) {
0 -> {
reloadApp()
}
1 -> {
val bundleFile =
File(
packageParam.appInfo.dataDir,
"${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
)
if (bundleFile.exists()) bundleFile.delete()
// Always perform UI operations on the main thread to avoid runtime exceptions
Handler(Looper.getMainLooper()).post {
try {
// Wrap with a dark dialog theme from module resources (AppCompat-based).
// Use the provided activity/context directly to avoid inflating module resources
// into the host process (some hosts fail to resolve module resources). We set
// colors programmatically on views instead of relying on theme attributes.
val themed = context
reloadApp()
// Prepare paths
val filesDir =
File(packageParam.appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() }
val configFile = File(filesDir, "loader.json")
val preloadsDir =
File(filesDir, HookScriptLoaderModule.PRELOADS_DIR).apply { mkdirs() }
// Read existing loader.json (guarded)
var customEnabled = false
var customUrl = ""
try {
if (configFile.exists()) {
val je = Utils.JSON.parseToJsonElement(configFile.readText())
val custom = je.jsonObject["customLoadUrl"]?.jsonObject
if (custom != null) {
customEnabled = custom["enabled"]?.jsonPrimitive?.booleanOrNull ?: false
customUrl = custom["url"]?.jsonPrimitive?.contentOrNull ?: ""
}
}
} catch (t: Throwable) {
Log.e("Failed to parse loader.json: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: loader.json parse failed: ${android.util.Log.getStackTraceString(t)}"
)
}
.show()
// Build the dialog UI programmatically to avoid inflating module resources which
// can
// fail when injected into some host apps.
val padding = (themed.resources.displayMetrics.density * 16).toInt()
val container =
LinearLayout(themed).apply {
orientation = LinearLayout.VERTICAL
setPadding(padding, padding, padding, padding)
setBackgroundColor(0xFF121212.toInt())
}
// Helper to create a labeled switch row
fun addSwitchRow(labelText: String, checked: Boolean): Pair<TextView, Switch> {
val rowLp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
val row =
LinearLayout(themed).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = rowLp
}
val tvLp =
LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
val tv =
TextView(themed).apply {
text = labelText
setTextColor(0xFFFFFFFF.toInt())
textSize = 15f
layoutParams = tvLp
}
val swLp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
val sw =
Switch(themed).apply {
isChecked = checked
layoutParams = swLp
}
row.addView(tv)
row.addView(sw)
container.addView(row)
return Pair(tv, sw)
}
// Create rows
val safeRow = addSwitchRow("Safe mode (disable add-ons)", false)
val safeSwitch = safeRow.second
val customRow = addSwitchRow("Load from custom URL", false)
val customSwitch = customRow.second
// URL label + input
val lblUrl =
TextView(themed).apply {
text = "Custom bundle URL"
setTextColor(0xFFFFFFFF.toInt())
textSize = 13f
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
lp.topMargin = (themed.resources.displayMetrics.density * 8).toInt()
layoutParams = lp
}
val urlInput =
EditText(themed).apply {
hint = "https://example.com/bundle.js"
setTextColor(0xFFFFFFFF.toInt())
setHintTextColor(0xFFBDBDBD.toInt())
setBackgroundColor(0xFF1E1E1E.toInt())
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
lp.topMargin = (themed.resources.displayMetrics.density * 6).toInt()
layoutParams = lp
visibility = View.GONE
}
container.addView(lblUrl)
container.addView(urlInput)
// Buttons row - keep primary actions (Delete / Close) on one row,
// place the Save & Reload button on a separate row below so it's not squeezed.
val buttonsRow =
LinearLayout(themed).apply {
orientation = LinearLayout.HORIZONTAL
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
lp.topMargin = (themed.resources.displayMetrics.density * 12).toInt()
layoutParams = lp
}
fun createButton(text: String): Button {
return Button(themed).apply {
this.text = text
setAllCaps(false)
setTextColor(0xFFFFFFFF.toInt())
val bLp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
bLp.marginEnd = (themed.resources.displayMetrics.density * 8).toInt()
layoutParams = bLp
// default transparent background so host drawable still applies
setBackgroundColor(0x00000000)
}
}
val deleteBtn = createButton("Delete bundle")
val closeBtn = createButton("Close")
val saveBtn =
createButton("Save & Reload").apply {
// accent background + readable text
setBackgroundColor(0xFFBB86FC.toInt())
setTextColor(0xFF000000.toInt())
}
// Add Delete and Close to the top (horizontal) row
buttonsRow.addView(deleteBtn)
buttonsRow.addView(closeBtn)
container.addView(buttonsRow)
// Create a separate row for the Save button so it sits below and can span full
// width
val saveRow =
LinearLayout(themed).apply {
orientation = LinearLayout.HORIZONTAL
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
lp.topMargin = (themed.resources.displayMetrics.density * 8).toInt()
layoutParams = lp
}
val saveLp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
saveRow.addView(saveBtn, saveLp)
container.addView(saveRow)
// Expose these variables below using the local names expected by the rest of the
// function (safeSwitch, customSwitch, urlInput, saveBtn, deleteBtn, closeBtn).
// They are already defined above as local vals.
// Initialize state - check for any sentinel file that indicates safe mode.
// Be robust: accept any file that starts with "00_" (legacy sentinel pattern)
// or the legacy "rv_safe_mode.js".
val safePresent =
preloadsDir.listFiles()?.any {
it.isFile && (it.name.startsWith("00_") || it.name == "rv_safe_mode.js")
}
?: false
safeSwitch.isChecked = safePresent
customSwitch.isChecked = customEnabled
urlInput.setText(customUrl)
urlInput.visibility = if (customEnabled) View.VISIBLE else View.GONE
// Confirmation modal helper (keeps previous messaging/behavior)
fun showReloadConfirmModal(parentCtx: Context, turningOn: Boolean) {
try {
val dialogContainer =
LinearLayout(parentCtx).apply {
orientation = LinearLayout.VERTICAL
val pad =
(parentCtx.resources.displayMetrics.density * 16)
.toInt()
setPadding(pad, pad, pad, pad)
setBackgroundColor(0xFF121212.toInt())
}
val message =
TextView(parentCtx).apply {
text =
if (turningOn) {
"All add-ons will be temporarily disabled upon reload."
} else {
"All add-ons will load normally."
}
setTextColor(0xFFFFFFFF.toInt())
textSize = 14f
setPadding(0, 0, 0, 8)
}
dialogContainer.addView(message)
val buttonsRow =
LinearLayout(parentCtx).apply {
orientation = LinearLayout.HORIZONTAL
val lp =
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
layoutParams = lp
}
val reloadNowBtn =
Button(parentCtx).apply {
text = "Reload Now"
setTextColor(0xFF000000.toInt())
setBackgroundColor(0xFFBB86FC.toInt())
setOnClickListener {
try {
reloadApp()
} catch (_: Throwable) {}
}
}
val laterBtn =
Button(parentCtx).apply {
text = "Later"
setTextColor(0xFFFFFFFF.toInt())
}
val lpBtn =
LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
)
.apply { marginEnd = 8 }
val lpBtn2 =
LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
)
buttonsRow.addView(reloadNowBtn, lpBtn)
buttonsRow.addView(laterBtn, lpBtn2)
dialogContainer.addView(buttonsRow)
val dlg = AlertDialog.Builder(parentCtx).setView(dialogContainer).create()
laterBtn.setOnClickListener { dlg.dismiss() }
dlg.show()
} catch (t: Throwable) {
Log.e("Failed to show reload confirm modal: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: Reload confirm modal failed: ${android.util.Log.getStackTraceString(t)}"
)
try {
AlertDialog.Builder(parentCtx)
.setTitle("Reload now?")
.setMessage(
if (turningOn)
"All add-ons will be temporarily disabled upon reload."
else "All add-ons will load normally."
)
.setPositiveButton("Reload Now") { _, _ -> reloadApp() }
.setNegativeButton("Later", null)
.show()
} catch (_: Throwable) {}
}
}
// Safe switch toggles the safe preload file immediately (no automatic reload),
// then prompts the user whether to reload now or later (matching React UI).
safeSwitch.setOnCheckedChangeListener {
_: android.widget.CompoundButton,
checked: Boolean ->
try {
val safeFile = File(preloadsDir, "00_rv_safe_mode.js")
if (checked) {
val safeJs =
"(function(){try{var g=(typeof globalThis!=='undefined'?globalThis:(typeof global!=='undefined'?global:this));if(!g)g={};if(!g.console)g.console={log:function(){},error:function(){},warn:function(){},info:function(){}};g.__SHIGGY_SAFE_MODE=true;}catch(e){}})();"
runCatching { safeFile.writeText(safeJs) }.onFailure { t ->
Log.e("Failed to write safe preload: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: write safe preload failed: ${android.util.Log.getStackTraceString(t)}"
)
}
} else {
// Remove any safe-mode sentinel files (be forgiving about filenames)
try {
preloadsDir
.listFiles()
?.filter {
it.isFile &&
(it.name.startsWith("00_") ||
it.name == "rv_safe_mode.js")
}
?.forEach { it.delete() }
} catch (_: Throwable) {}
}
} catch (t: Throwable) {
Log.e("Safe toggle persistence failed: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: Safe toggle persistence failed: ${android.util.Log.getStackTraceString(t)}"
)
}
// Show confirmation (no automatic reload) with option to reload now
showReloadConfirmModal(themed, checked)
}
// Custom URL toggle behavior
customSwitch.setOnCheckedChangeListener {
_: android.widget.CompoundButton,
checked: Boolean ->
urlInput.visibility = if (checked) View.VISIBLE else View.GONE
}
// Save button: persist loader.json and safe file state, then reload
saveBtn.setOnClickListener {
try {
val enabled = customSwitch.isChecked
val url = urlInput.text?.toString() ?: ""
val escapedUrl = url.replace("\\", "\\\\").replace("\"", "\\\"")
val json =
"{\"customLoadUrl\":{\"enabled\":$enabled,\"url\":\"$escapedUrl\"}}"
runCatching { configFile.writeText(json) }.onFailure { t ->
Log.e("Failed to write loader.json: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: write loader.json failed: ${android.util.Log.getStackTraceString(t)}"
)
}
// ensure safe file persisted if switch is checked
val safeFile = File(preloadsDir, "00_rv_safe_mode.js")
if (safeSwitch.isChecked) {
val safeJs =
"(function(){try{var g=(typeof globalThis!=='undefined'?globalThis:(typeof global!=='undefined'?global:this));if(!g)g={};if(!g.console)g.console={log:function(){},error:function(){},warn:function(){},info:function(){}};g.__SHIGGY_SAFE_MODE=true;}catch(e){}})();"
runCatching { safeFile.writeText(safeJs) }
} else {
// Ensure we remove any existing safe-mode sentinel files
try {
preloadsDir
.listFiles()
?.filter {
it.isFile &&
(it.name.startsWith("00_") ||
it.name == "rv_safe_mode.js")
}
?.forEach { it.delete() }
} catch (_: Throwable) {}
}
} catch (t: Throwable) {
Log.e("Failed to save loader.json: ${t.message}", t)
}
reloadApp()
}
// Delete bundle
deleteBtn.setOnClickListener {
try {
val bundleFile =
File(
packageParam.appInfo.dataDir,
"${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
)
if (bundleFile.exists()) bundleFile.delete()
} catch (t: Throwable) {
Log.e("Failed to delete bundle: ${t.message}", t)
}
reloadApp()
}
// Close button dismisses the created dialog
val dlg = AlertDialog.Builder(themed).setView(container).create()
dlg.show()
closeBtn.setOnClickListener { dlg.dismiss() }
} catch (e: Throwable) {
// Log and fallback to old simple dialog
Log.e("Dialog failed, falling back: ${e.message}", e)
XposedBridge.log(
"ShiggyXposed: Dialog failed: ${android.util.Log.getStackTraceString(e)}"
)
try {
AlertDialog.Builder(context)
.setTitle("ShiggyXposed Recovery Options")
.setItems(arrayOf("Reload", "Delete bundle.js")) { _, which ->
when (which) {
0 -> reloadApp()
1 -> {
val bundleFile =
File(
packageParam.appInfo.dataDir,
"${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}"
)
if (bundleFile.exists()) bundleFile.delete()
reloadApp()
}
}
}
.show()
} catch (t: Throwable) {
Log.e("Fallback dialog also failed: ${t.message}", t)
XposedBridge.log(
"ShiggyXposed: Fallback dialog failed: ${android.util.Log.getStackTraceString(t)}"
)
}
}
}
}
}