From 85795c69b9f24c57849809d1eabbab401c82fdd4 Mon Sep 17 00:00:00 2001 From: kmmiio99o <164557248+kmmiio99o@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:51:44 +0100 Subject: [PATCH] Make Xposed Recovery look better :P --- .../revenge/xposed/modules/LogBoxModule.kt | 722 ++++++++++++++++-- 1 file changed, 658 insertions(+), 64 deletions(-) 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 b657987..c8fa021 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,8 +1,25 @@ package io.github.revenge.xposed.modules +import android.animation.AnimatorSet +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator import android.app.AlertDialog import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.view.animation.DecelerateInterpolator +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView import android.widget.Toast +import androidx.core.view.setPadding import de.robv.android.xposed.XC_MethodReplacement import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers @@ -10,11 +27,12 @@ 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 io.github.revenge.xposed.Constants import kotlinx.coroutines.* +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import org.json.JSONObject import java.io.File -import java.net.HttpURLConnection -import java.net.URL object LogBoxModule : Module() { lateinit var packageParam: XC_LoadPackage.LoadPackageParam @@ -45,7 +63,7 @@ object LogBoxModule : Module() { try { Log.e("onContext called with context: $context") contextForMenu = context - + val possibleClasses = listOf( "com.facebook.react.devsupport.BridgeDevSupportManager", "com.facebook.react.devsupport.BridgelessDevSupportManager", @@ -53,7 +71,7 @@ object LogBoxModule : Module() { "com.facebook.react.devsupport.DevSupportManagerBase", "com.facebook.react.devsupport.DefaultDevSupportManager" ) - + var foundAny = false possibleClasses.forEach { className -> try { @@ -65,14 +83,14 @@ object LogBoxModule : Module() { Log.e("Class not found: $className - ${e.message}") } } - + if (!foundAny) { tryFindDevSupportClasses(context) } } catch (e: Exception) { } } - + private fun tryFindDevSupportClasses(context: Context) { try { val dexFile = packageParam.classLoader.javaClass.getDeclaredField("pathList") @@ -85,15 +103,14 @@ object LogBoxModule : Module() { private fun hookDevSupportManager(clazz: Class<*>, context: Context) { Log.e("Attempting to hook ${clazz.name}") - - // List all methods to see what's available + Log.e("Available methods in ${clazz.simpleName}:") clazz.methods.forEach { method -> if (method.name.contains("Dev") || method.name.contains("Reload") || method.name.contains("Options")) { Log.e(" - ${method.name}") } } - + try { try { val handleReloadJSMethod = clazz.methods.firstOrNull { it.name == "handleReloadJS" } @@ -105,7 +122,6 @@ object LogBoxModule : Module() { return null } }) - } else { } } catch (e: Exception) { Log.e("Failed to hook handleReloadJS: ${e.message}") @@ -126,10 +142,10 @@ object LogBoxModule : Module() { } catch (e: Exception) { Log.e("Failed to get context from DevSupport (non-fatal): ${e.message}") } - + val finalContext = activityContext ?: contextForMenu ?: context Log.e("Using context: $finalContext (type: ${finalContext.javaClass.name})") - + showRecoveryMenu(finalContext) } catch (e: Exception) { e.printStackTrace() @@ -137,7 +153,6 @@ object LogBoxModule : Module() { return null } }) - } else { } } catch (e: Exception) { } @@ -150,9 +165,9 @@ object LogBoxModule : Module() { Log.e("getContextFromDevSupport: instance is null") return null } - + return try { - + // what if we just did this and searched for literally everything val helpers = listOf( "mReactInstanceDevHelper", @@ -160,7 +175,7 @@ object LogBoxModule : Module() { "mReactInstanceManager", "mApplicationContext" ) - + for (helperName in helpers) { try { Log.e("Trying field: $helperName") @@ -169,22 +184,22 @@ object LogBoxModule : Module() { Log.e("Field $helperName not found, skipping") continue } - + val helper = helperField.get(instance) if (helper == null) { Log.e("Field $helperName is null, skipping") continue } - + if (helper is Context) { Log.e("Field $helperName is a Context, returning it") return helper } - - val getCurrentActivityMethod = helper.javaClass.methods.firstOrNull { - it.name == "getCurrentActivity" + + val getCurrentActivityMethod = helper.javaClass.methods.firstOrNull { + it.name == "getCurrentActivity" } - + if (getCurrentActivityMethod != null) { val ctx = getCurrentActivityMethod.invoke(helper) as? Context if (ctx != null) { @@ -196,7 +211,7 @@ object LogBoxModule : Module() { Log.e("Error trying $helperName: ${e.message}") } } - + Log.e("Could not get context from DevSupport object using any method") null } catch (e: Exception) { @@ -205,22 +220,157 @@ object LogBoxModule : Module() { } } + // material design helper functions + private fun dpToPx(context: Context, dp: Int): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.toFloat(), + context.resources.displayMetrics + ).toInt() + } + + private fun getM3Colors(): M3Colors { + // color scheme + return M3Colors( + surface = Color.parseColor("#000000"), + surfaceVariant = Color.parseColor("#1A1A1A"), + onSurface = Color.parseColor("#FFFFFF"), + onSurfaceVariant = Color.parseColor("#CCCCCC"), + primary = Color.parseColor("#FFFFFF"), + onPrimary = Color.parseColor("#000000"), + primaryContainer = Color.parseColor("#2A2A2A"), + onPrimaryContainer = Color.parseColor("#FFFFFF"), + error = Color.parseColor("#FF6B6B") + ) + } + + private data class M3Colors( + val surface: Int, + val surfaceVariant: Int, + val onSurface: Int, + val onSurfaceVariant: Int, + val primary: Int, + val onPrimary: Int, + val primaryContainer: Int, + val onPrimaryContainer: Int, + val error: Int + ) + + private fun createM3Background(context: Context, color: Int, cornerRadius: Float): GradientDrawable { + return GradientDrawable().apply { + setColor(color) + setCornerRadius(dpToPx(context, cornerRadius.toInt()).toFloat()) + } + } + + private fun createButton(context: Context, text: String, colors: M3Colors, onClick: () -> Unit): View { + return LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + background = createM3Background(context, colors.primaryContainer, 12f) + setPadding( + dpToPx(context, 32), + dpToPx(context, 12), + dpToPx(context, 32), + dpToPx(context, 12) + ) + isClickable = true + isFocusable = true + + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dpToPx(context, 48) + ).apply { + setMargins(0, dpToPx(context, 8), 0, 0) + } + layoutParams = params + + addView(TextView(context).apply { + this.text = text + setTextColor(colors.onPrimaryContainer) + textSize = 15f + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + gravity = Gravity.CENTER + }) + + // Ripple effect + val rippleDrawable = android.graphics.drawable.RippleDrawable( + ColorStateList.valueOf(Color.argb(40, 255, 255, 255)), + createM3Background(context, colors.primaryContainer, 12f), + null + ) + background = rippleDrawable + + setOnClickListener { onClick() } + } + } + private fun showRecoveryMenu(context: Context) { Log.e("showRecoveryMenu called with context: $context") try { - val options = arrayOf( - if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode", - "Reset Bundle", - "Reload App" + val colors = getM3Colors() + lateinit var dialog: AlertDialog + + val container = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24) + ) + background = createM3Background(context, colors.surface, 24f) + } + + val titleView = TextView(context).apply { + text = "KettuXposed Recovery" + textSize = 20f + setTextColor(colors.onSurface) + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) + setPadding(0, 0, 0, dpToPx(context, 24)) + gravity = Gravity.CENTER + } + container.addView(titleView) + + // safe Mode Button + val safeModeText = if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode" + container.addView(createButton(context, safeModeText, colors) { + dialog.dismiss() + toggleSafeMode(context) + }) + + // load Custom Bundle Button + container.addView(createButton(context, "Load Custom Bundle", colors) { + dialog.dismiss() + showCustomBundleDialog(context) + }) + + // refetch Bundle Button + container.addView(createButton(context, "Refetch Bundle", colors) { + dialog.dismiss() + showConfirmAction( + context, "Refetch Bundle", + "This will download the latest bundle from Github." + ) { refetchBundle(context) } + }) + + // reload App Button + container.addView(createButton(context, "Reload App", colors) { + dialog.dismiss() + reloadApp() + }) + + dialog = AlertDialog.Builder(context) + .setView(container) + .create() + + dialog.window?.setBackgroundDrawable( + createM3Background(context, Color.TRANSPARENT, 24f) ) - AlertDialog.Builder(context) - .setTitle("KettuXposed Recovery Menu") - .setItems(options) { _, which -> - handleMenuSelection(context, which) - } - .setNegativeButton("Cancel", null) - .show() + constrainContainerWidth(context, container) + dialog.show() + setDialogWindowWidth(dialog, context) Log.e("Recovery menu shown successfully") } catch (e: Exception) { Log.e("Error showing recovery menu: ${e.message}", e) @@ -228,21 +378,390 @@ object LogBoxModule : Module() { } } - private fun handleMenuSelection(context: Context, index: Int) { - when (index) { - 0 -> toggleSafeMode(context) - 1 -> confirmAction(context, "reset bundle") { resetBundle(context) } - 2 -> reloadApp() + private fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) { + val colors = getM3Colors() + + val container = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24) + ) + background = createM3Background(context, colors.surface, 24f) } + + val titleView = TextView(context).apply { + text = title + textSize = 18f + setTextColor(colors.onSurface) + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) + setPadding(0, 0, 0, dpToPx(context, 16)) + } + container.addView(titleView) + + val messageView = TextView(context).apply { + text = message + textSize = 14f + setTextColor(colors.onSurfaceVariant) + setPadding(0, 0, 0, dpToPx(context, 24)) + } + container.addView(messageView) + + val buttonContainer = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.END + } + + val cancelButton = TextView(context).apply { + text = "Cancel" + setTextColor(colors.primary) + textSize = 14f + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + setPadding( + dpToPx(context, 20), + dpToPx(context, 12), + dpToPx(context, 20), + dpToPx(context, 12) + ) + isClickable = true + } + + val confirmButton = TextView(context).apply { + text = "Confirm" + setTextColor(colors.primary) + textSize = 14f + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + setPadding( + dpToPx(context, 20), + dpToPx(context, 12), + dpToPx(context, 20), + dpToPx(context, 12) + ) + isClickable = true + } + + val dialog = AlertDialog.Builder(context) + .setView(container) + .create() + + cancelButton.setOnClickListener { dialog.dismiss() } + confirmButton.setOnClickListener { + dialog.dismiss() + action() + } + + buttonContainer.addView(cancelButton) + buttonContainer.addView(confirmButton) + container.addView(buttonContainer) + + dialog.window?.setBackgroundDrawable( + createM3Background(context, Color.TRANSPARENT, 24f) + ) + + constrainContainerWidth(context, container) + dialog.show() + setDialogWindowWidth(dialog, context) } - private fun confirmAction(context: Context, actionText: String, action: () -> Unit) { - AlertDialog.Builder(context) - .setTitle("Confirm Action") - .setMessage("Are you sure you want to $actionText?") - .setPositiveButton("Confirm") { _, _ -> action() } - .setNegativeButton("Cancel", null) - .show() + private fun showCustomBundleDialog(context: Context) { + val colors = getM3Colors() + val filesDir = File(context.filesDir, "pyoncord") + val configFile = File(filesDir, "loader_config.json") + var currentUrl: String? = null + var isEnabled = false + + if (configFile.exists()) { + try { + val json = JSONObject(configFile.readText()) + val custom = json.optJSONObject("customLoadUrl") + if (custom != null) { + isEnabled = custom.optBoolean("enabled", false) + currentUrl = custom.optString("url", "") + } else { + isEnabled = false + currentUrl = "" + } + } catch (_: Exception) { + } + } + + val container = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24) + ) + background = createM3Background(context, colors.surface, 24f) + } + + val titleView = TextView(context).apply { + text = "Custom Bundle URL" + textSize = 18f + setTextColor(colors.onSurface) + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) + setPadding(0, 0, 0, dpToPx(context, 16)) + } + container.addView(titleView) + + val toggleContainer = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, 0, 0, dpToPx(context, 16)) + } + layoutParams = params + } + + val toggleLabel = TextView(context).apply { + text = "Enable Custom URL" + textSize = 14f + setTextColor(colors.onSurface) + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + layoutParams = LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f + ) + } + + // switch container + val trackPadding = dpToPx(context, 4) + val switchTrackWidth = dpToPx(context, 52) + val switchTrackHeight = dpToPx(context, 32) + val trackCornerRadius = switchTrackHeight / 2f + + val switchContainer = FrameLayout(context).apply { + background = createM3Background(context, colors.surfaceVariant, trackCornerRadius) + layoutParams = LinearLayout.LayoutParams( + switchTrackWidth, + switchTrackHeight + ) + tag = isEnabled + isClickable = true + } + + // create the thumb with proper sizing + val thumbSize = switchTrackHeight - (trackPadding * 2) + val thumbCornerRadius = thumbSize / 2f + + val switchThumb = View(context).apply { + background = createM3Background(context, if (isEnabled) colors.onPrimary else colors.onSurfaceVariant, thumbCornerRadius) + val params = FrameLayout.LayoutParams(thumbSize, thumbSize) + + val thumbLeft = if (isEnabled) { + switchTrackWidth - thumbSize - trackPadding + } else { + trackPadding + } + + params.leftMargin = thumbLeft + params.topMargin = trackPadding + + layoutParams = params + } + + if (isEnabled) { + switchContainer.background = createM3Background(context, colors.primary, trackCornerRadius) + } + + switchContainer.addView(switchThumb) + + toggleContainer.addView(toggleLabel) + toggleContainer.addView(switchContainer) + container.addView(toggleContainer) + + val urlInput = EditText(context).apply { + hint = "http://localhost:4040/bundle.js" + setTextColor(colors.onSurface) + setHintTextColor(colors.onSurfaceVariant) + background = createM3Background(context, colors.surfaceVariant, 12f) + inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI + setText(currentUrl ?: "") + textSize = 14f + setPadding( + dpToPx(context, 16), + dpToPx(context, 12), + dpToPx(context, 16), + dpToPx(context, 12) + ) + this.isEnabled = isEnabled + alpha = if (isEnabled) 1f else 0.5f + val params = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, 0, 0, dpToPx(context, 24)) + } + layoutParams = params + } + container.addView(urlInput) + + switchContainer.setOnClickListener { + val currentState = switchContainer.tag as Boolean + val newState = !currentState + switchContainer.tag = newState + + val thumbParams = switchThumb.layoutParams as FrameLayout.LayoutParams + val targetLeftMargin = if (newState) { + switchTrackWidth - thumbSize - trackPadding + } else { + trackPadding + } + + // Animate the thumb movement + val animator = ValueAnimator.ofInt(thumbParams.leftMargin, targetLeftMargin).apply { + duration = 200 + interpolator = DecelerateInterpolator() + addUpdateListener { valueAnimator -> + val animatedValue = valueAnimator.animatedValue as Int + thumbParams.leftMargin = animatedValue + switchThumb.layoutParams = thumbParams + } + } + animator.start() + + // animate track color change + val trackAnimator = ValueAnimator.ofObject( + ArgbEvaluator(), + if (currentState) colors.primary else colors.surfaceVariant, + if (newState) colors.primary else colors.surfaceVariant + ).apply { + duration = 200 + addUpdateListener { animator -> + val color = animator.animatedValue as Int + switchContainer.background = createM3Background(context, color, trackCornerRadius) + } + } + trackAnimator.start() + + // animate thumb color change + val thumbAnimator = ValueAnimator.ofObject( + ArgbEvaluator(), + if (currentState) colors.onPrimary else colors.onSurfaceVariant, + if (newState) colors.onPrimary else colors.onSurfaceVariant + ).apply { + duration = 200 + addUpdateListener { animator -> + val color = animator.animatedValue as Int + switchThumb.background = createM3Background(context, color, thumbCornerRadius) + } + } + thumbAnimator.start() + + urlInput.animate() + .alpha(if (newState) 1f else 0.5f) + .setDuration(200) + .withEndAction { + urlInput.isEnabled = newState + } + .start() + } + + val buttonContainer = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.END + } + + val cancelButton = TextView(context).apply { + text = "Cancel" + setTextColor(colors.primary) + textSize = 14f + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + setPadding( + dpToPx(context, 20), + dpToPx(context, 12), + dpToPx(context, 20), + dpToPx(context, 12) + ) + isClickable = true + } + + val saveButton = TextView(context).apply { + text = "Save" + setTextColor(colors.primary) + textSize = 14f + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + setPadding( + dpToPx(context, 20), + dpToPx(context, 12), + dpToPx(context, 20), + dpToPx(context, 12) + ) + isClickable = true + } + + val dialog = AlertDialog.Builder(context) + .setView(container) + .create() + + cancelButton.setOnClickListener { dialog.dismiss() } + saveButton.setOnClickListener { + try { + val url = urlInput.text?.toString()?.trim() ?: "" + val enabled = switchContainer.tag as Boolean + + if (enabled && url.isNotEmpty()) { + setCustomBundleURL(context, url, true) + dialog.dismiss() + } else if (!enabled) { + setCustomBundleURL(context, url.ifEmpty { "http://localhost:4040/bundle.js" }, false) + dialog.dismiss() + } else { + Toast.makeText(context, "Please enter a valid URL", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + val err = e.message ?: "unknown error" + Toast.makeText(context, "Failed to set custom bundle: $err", Toast.LENGTH_LONG).show() + } + } + + buttonContainer.addView(cancelButton) + buttonContainer.addView(saveButton) + container.addView(buttonContainer) + + dialog.window?.setBackgroundDrawable( + createM3Background(context, Color.TRANSPARENT, 24f) + ) + + constrainContainerWidth(context, container) + dialog.show() + setDialogWindowWidth(dialog, context) + } + + private fun setCustomBundleURL(context: Context, url: String, enabled: Boolean) { + try { + val filesDir = File(context.dataDir, Constants.FILES_DIR) + filesDir.mkdirs() + val configFile = File(filesDir, "loader.json") + + val cfg = io.github.revenge.xposed.modules.LoaderConfig( + io.github.revenge.xposed.modules.CustomLoadUrl(enabled, url) + ) + + val jsonText = io.github.revenge.xposed.Utils.JSON.encodeToString(cfg) + configFile.writeText(jsonText) + + // remove cached bundle so UpdaterModule will fetch the custom one + val cacheBundle = File(context.dataDir, "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}") + if (enabled) { + if (cacheBundle.exists()) cacheBundle.delete() + Toast.makeText(context, "Custom bundle enabled", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "Custom bundle disabled", 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 isSafeModeEnabled(context: Context): Boolean { @@ -298,26 +817,23 @@ object LogBoxModule : Module() { } } - private fun resetBundle(context: Context) { + private fun refetchBundle(context: Context) { try { val pyoncordDir = getPyoncordDirectory(context) val bundleFile = File(pyoncordDir, "bundle.js") val backupFile = File(pyoncordDir, "bundle.js.backup") - val configFile = File(pyoncordDir, "loader_config.json") - bundleFile.delete() - backupFile.delete() - - if (configFile.exists()) { - val config = JSONObject(configFile.readText()) - config.put("customLoadUrlEnabled", false) - configFile.writeText(config.toString()) + if (bundleFile.exists()) { + backupFile.delete() + bundleFile.renameTo(backupFile) + Log.e("Bundle moved to backup") } reloadApp() + } catch (e: Exception) { - Log.e("Error resetting bundle: ${e.message}") - showError(context, "Failed to reset bundle", e.message) + Log.e("Error refetching bundle: ${e.message}") + showError(context, "Failed to refetch bundle", e.message) } } @@ -329,11 +845,89 @@ object LogBoxModule : Module() { return dir } - private fun showError(context: Context, title: String, message: String?) { - AlertDialog.Builder(context) - .setTitle(title) - .setMessage(message ?: "An unknown error occurred") - .setPositiveButton("OK", null) - .show() + private fun constrainContainerWidth(context: Context, container: LinearLayout) { + try { + val maxW = (context.resources.displayMetrics.widthPixels * 0.88).toInt() + val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT) + container.layoutParams = lp + try { + container.minimumWidth = maxW + } catch (_: Exception) {} + } catch (_: Exception) { + } } -} \ No newline at end of file + + private fun setDialogWindowWidth(dialog: AlertDialog, context: Context) { + try { + val screenW = context.resources.displayMetrics.widthPixels + val maxByPercent = (screenW * 0.88).toInt() + val maxByDp = dpToPx(context, 380) + val targetW = if (maxByPercent < maxByDp) maxByPercent else maxByDp + + dialog.window?.setLayout(targetW, LinearLayout.LayoutParams.WRAP_CONTENT) + } catch (_: Exception) { + } + } + + private fun showError(context: Context, title: String, message: String?) { + val colors = getM3Colors() + + val container = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding( + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24) + ) + background = createM3Background(context, colors.surface, 24f) + } + + val titleView = TextView(context).apply { + text = title + textSize = 18f + setTextColor(colors.error) + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) + setPadding(0, 0, 0, dpToPx(context, 16)) + } + container.addView(titleView) + + val messageView = TextView(context).apply { + text = message ?: "An unknown error occurred" + textSize = 14f + setTextColor(colors.onSurfaceVariant) + setPadding(0, 0, 0, dpToPx(context, 24)) + } + container.addView(messageView) + + val okButton = TextView(context).apply { + text = "OK" + setTextColor(colors.primary) + textSize = 14f + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + gravity = Gravity.END + setPadding( + dpToPx(context, 20), + dpToPx(context, 12), + dpToPx(context, 20), + dpToPx(context, 12) + ) + isClickable = true + } + + val dialog = AlertDialog.Builder(context) + .setView(container) + .create() + + okButton.setOnClickListener { dialog.dismiss() } + container.addView(okButton) + + dialog.window?.setBackgroundDrawable( + createM3Background(context, Color.TRANSPARENT, 24f) + ) + + constrainContainerWidth(context, container) + dialog.show() + setDialogWindowWidth(dialog, context) + } +}