diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47e458b..dc65fa5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,65 +1,79 @@ name: Build APK -on: [push] + +on: + push: + branches: ["**"] + pull_request: + +permissions: + contents: read + +env: + JAVA_VERSION: 17 jobs: build: - name: Build and push runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 + - name: Checkout + uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - name: Setup Java + uses: actions/setup-java@v4 with: - distribution: "temurin" - java-version: "17" + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + cache: gradle - - name: "Grant execution permission" + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Make Gradle executable run: chmod +x ./gradlew - - name: "Build APKs" + - name: Optional Keystore (if provided) + env: + KEYSTORE: ${{ secrets.KEYSTORE }} run: | - # Retry the Gradle build up to 3 times to mitigate transient cache/service errors - ATTEMPT=0 - until [ $ATTEMPT -ge 3 ] - do - ./gradlew build --no-daemon --stacktrace && break - ATTEMPT=$((ATTEMPT+1)) - echo "Gradle build failed, retrying ($ATTEMPT/3)..." - sleep 5 - done - if [ $ATTEMPT -ge 3 ]; then - echo "Gradle build failed after 3 attempts" - exit 1 + if [ -n "$KEYSTORE" ]; then + echo "$KEYSTORE" | base64 -d > signing-key.jks + ls -l signing-key.jks + else + echo "No keystore provided, skipping..." fi - - name: "Setup directories" + - name: Assemble Debug and Release + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + GRADLE_OPTS: -Dorg.gradle.daemon=false run: | - mkdir -p /tmp/ShiggyXposedbuild/apks - mkdir -p /tmp/ShiggyXposedbuild/tools + if [ -f signing-key.jks ]; then + ./gradlew assembleDebug assembleRelease \ + -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \ + -Pandroid.injected.signing.store.password="$KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$KEY_PASSWORD" + else + ./gradlew assembleDebug assembleRelease + fi - - name: "Move debug APK" - run: mv ./app/build/outputs/apk/debug/app-debug.apk /tmp/ShiggyXposedbuild/apks + - name: Collect APKs + run: | + mkdir -p dist + cp app/build/outputs/apk/debug/*.apk dist/ || true + # Prefer signed release if produced + if ls app/build/outputs/apk/release/*-signed.apk >/dev/null 2>&1; then + cp app/build/outputs/apk/release/*-signed.apk dist/ + else + cp app/build/outputs/apk/release/*.apk dist/ + fi + ls -1 dist - - name: "Download uber-apk-signer" - run: wget -nv "https://github.com/patrickfav/uber-apk-signer/releases/download/v1.2.1/uber-apk-signer-1.2.1.jar" -O /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar - - - name: "Sign release APK" - run: java -jar /tmp/ShiggyXposedbuild/tools/uber-apk-signer.jar --apks ./app/build/outputs/apk/release/app-release-unsigned.apk --out /tmp/ShiggyXposedbuild/apks/ - - - name: "Rename release APK" - run: mv /tmp/ShiggyXposedbuild/apks/app-release-aligned-debugSigned.apk /tmp/ShiggyXposedbuild/apks/app-release.apk - - - name: "Upload debug APK" + - name: Upload All APKs uses: actions/upload-artifact@v4 with: - name: app-debug - path: /tmp/ShiggyXposedbuild/apks/app-debug.apk - - - name: "Upload release APK" - uses: actions/upload-artifact@v4 - with: - name: app-release - path: /tmp/ShiggyXposedbuild/apks/app-release.apk + name: apks + path: dist/*.apk + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc7ac79..9477ef3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,19 +1,23 @@ name: Build Release + on: workflow_dispatch: inputs: versionName: required: true - description: This releases version name + description: This release's version name default: "1.0.0" versionCode: required: true - description: This releases version code + description: This release's version code default: "1000" permissions: contents: write +env: + JAVA_VERSION: 17 + jobs: build: runs-on: ubuntu-latest @@ -23,106 +27,79 @@ jobs: with: fetch-depth: 0 - - name: Setup JDK 17 - uses: actions/setup-java@v3 + - name: Setup JDK + uses: actions/setup-java@v4 with: - distribution: "temurin" - java-version: 17 + java-version: ${{ env.JAVA_VERSION }} + distribution: temurin + cache: gradle - name: Setup Android SDK - uses: android-actions/setup-android@v2.0.10 + uses: android-actions/setup-android@v2 - name: Set Version uses: chkfung/android-version-actions@v1.1 with: gradlePath: app/build.gradle.kts - versionCode: ${{github.event.inputs.versionCode}} - versionName: ${{github.event.inputs.versionName}} + versionCode: ${{ github.event.inputs.versionCode }} + versionName: ${{ github.event.inputs.versionName }} - - name: Bump Version - continue-on-error: true + - name: Commit Version Bump (if changed) run: | - git config --local user.email "actions@github.com" - git config --local user.name "GitHub Actions" + git config user.email "actions@github.com" + git config user.name "GitHub Actions" git add app/build.gradle.kts - if ! git diff --quiet app/build.gradle.kts; then - git commit -m "chore: Bump version to ${{github.event.inputs.versionName}} (${{github.event.inputs.versionCode}})" - git push + if git diff --cached --quiet; then + echo "No version change." else - echo "No changes to version file, skipping commit" + git commit -m "chore: bump version to ${{ github.event.inputs.versionName }} (${{ github.event.inputs.versionCode }})" + git push fi - - name: Build Signed APK + - name: Prepare Keystore run: | - # Ensure keystore secret is present - if [ -z "${{ secrets.keystore }}" ]; then - echo "Keystore secret missing, aborting" - exit 1 - fi + echo "${{ secrets.KEYSTORE }}" | base64 -d > signing-key.jks + ls -l signing-key.jks - echo "${{ secrets.keystore }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks - chmod +x ./gradlew - - # Retry gradle up to 3 times to mitigate transient network/cache service issues - ATTEMPT=0 - until [ $ATTEMPT -ge 3 ] - do - ./gradlew packageReleaseUniversalApk \ - -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \ - -Pandroid.injected.signing.store.password=${{ secrets.keystore_password }} \ - -Pandroid.injected.signing.key.alias=${{ secrets.key_alias }} \ - -Pandroid.injected.signing.key.password=${{ secrets.key_password }} --no-daemon --stacktrace && break - - ATTEMPT=$((ATTEMPT+1)) - echo "Gradle build failed, retrying ($ATTEMPT/3)..." - sleep 5 - done - - if [ $ATTEMPT -ge 3 ]; then - echo "Gradle build failed after 3 attempts" - exit 1 - fi - - - name: Release + - name: Build Universal APKs + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + GRADLE_OPTS: -Dorg.gradle.daemon=false run: | - set -e - echo "Locating built APK(s) under app/build/outputs..." - # Find APK files produced by the build in common output locations - apk_paths=$(find app/build/outputs -type f -name "*.apk" 2>/dev/null || true) - if [ -z "$apk_paths" ]; then - echo "ERROR: No APK files found under app/build/outputs" - echo "Diagnostics: top-level listing of app/build/outputs:" - ls -la app/build/outputs || true - echo "Diagnostics: recursive listing (files only):" - find app/build/outputs -type f -maxdepth 6 -print || true - echo "Failing the job so you can inspect the build logs and diagnostics above." - exit 1 - fi - # Prepare a directory to collect APKs for the release - mkdir -p release-apks - echo "Copying found APK(s) to release-apks/ for upload:" - for apk in $apk_paths; do - echo " - $apk" - cp "$apk" "release-apks/$(basename "$apk")" - done - # Keep backward compatibility: if exactly one APK found, copy it to the root as app-release.apk - apk_count=$(echo "$apk_paths" | wc -w) - if [ "$apk_count" -eq 1 ]; then - single_apk=$(echo "$apk_paths") - cp "$single_apk" ./app-release.apk - echo "Single APK copied to ./app-release.apk" - else - echo "Multiple APKs found ($apk_count). All copied to release-apks/" - fi - git config --local user.email "actions@github.com" - git config --local user.name "GitHub Actions" - tag="${{ github.event.inputs.versionCode }}" - git tag "$tag" - git push origin "$tag" - # Create the GitHub release including all APK(s) we collected (debug + release) - gh release create "$tag" \ - --title "${{ github.event.inputs.versionName }}" \ - --generate-notes \ - release-apks/* + ./gradlew packageDebugUniversalApk packageReleaseUniversalApk \ + -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \ + -Pandroid.injected.signing.store.password="$KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$KEY_PASSWORD" + + - name: Collect APKs + run: | + mkdir dist + mv app/build/outputs/apk_from_bundle/debug/app-debug-universal.apk dist/app-debug.apk + mv app/build/outputs/apk_from_bundle/release/app-release-universal.apk dist/app-release.apk + ls -l dist + + - name: Upload APK Artifacts + uses: actions/upload-artifact@v4 + with: + name: release-apks + path: dist/*.apk + if-no-files-found: error + + - name: Create Tag and Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag=${{ github.event.inputs.versionCode }} + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "Tag $tag already exists, skipping creation." + else + git tag "$tag" + git push origin "$tag" + fi + gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" \ + --title "${{ github.event.inputs.versionName }}" \ + --generate-notes \ + dist/app-debug.apk dist/app-release.apk diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt index 8d3b261..9aba2dd 100644 --- a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt @@ -35,7 +35,7 @@ object HookStateHolder { class Main : Module(), IXposedHookLoadPackage, IXposedHookZygoteInit { private var hooked = false - private val modules = mutableListOf( + private val modules = mutableListOf( HookScriptLoaderModule, BridgeModule, AdditionalBridgeMethodsModule, diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt index 351e7c8..ad3f9ce 100644 --- a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt @@ -1,183 +1,423 @@ package ShiggyXposed.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.Intent -import android.net.Uri -import android.os.Build +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.util.TypedValue +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView import android.widget.Toast -import de.robv.android.xposed.callbacks.XC_LoadPackage -import ShiggyXposed.xposed.BuildConfig -import ShiggyXposed.xposed.Module -import ShiggyXposed.xposed.Utils -import ShiggyXposed.xposed.Utils.Log +import androidx.core.view.setPadding +import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.XposedHelpers +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.reloadApp +import ShiggyXposed.xposed.Utils.Log import kotlinx.coroutines.* -import org.json.JSONArray import org.json.JSONObject import java.io.File import java.net.HttpURLConnection import java.net.URL -import ShiggyXposed.xposed.Utils.Companion.reloadApp object LogBoxModule : Module() { lateinit var packageParam: XC_LoadPackage.LoadPackageParam private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var contextForMenu: Context? = null override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { this@LogBoxModule.packageParam = packageParam - val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") - val getUseDeveloperSupportMethod = dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" } + try { + val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") + val getUseDeveloperSupportMethod = + dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" } - getUseDeveloperSupportMethod.hook { - before { - result = true + getUseDeveloperSupportMethod.hook { + before { + result = true + } } - } - - listOf( - "com.facebook.react.devsupport.BridgeDevSupportManager", - "com.facebook.react.devsupport.BridgelessDevSupportManager" - ).mapNotNull { packageParam.classLoader.safeLoadClass(it) }.forEach { - hookDevSupportManager(it) + Log.e("Successfully hooked DCDReactNativeHost") + } catch (e: Exception) { + Log.e("Failed to hook DCDReactNativeHost: ${e.message}") } return@with } - private fun hookDevSupportManager(clazz: Class<*>) { - val showDevOptionsDialogMethod = clazz.methods.first { it.name == "showDevOptionsDialog" } + override fun onContext(context: Context) { + try { + Log.e("onContext called with context: $context") + contextForMenu = context - showDevOptionsDialogMethod.hook { - before { + val possibleClasses = listOf( + "com.facebook.react.devsupport.BridgeDevSupportManager", + "com.facebook.react.devsupport.BridgelessDevSupportManager", + "com.facebook.react.devsupport.DevSupportManagerImpl", + "com.facebook.react.devsupport.DevSupportManagerBase", + "com.facebook.react.devsupport.DefaultDevSupportManager" + ) + + var foundAny = false + possibleClasses.forEach { className -> try { - val context = getContextFromDevSupport(clazz, thisObject) - if (context != null) { - showRecoveryMenu(context) - } - result = null + val clazz = packageParam.classLoader.loadClass(className) + Log.e("Found class: $className") + hookDevSupportManager(clazz, context) + foundAny = true } catch (e: Exception) { - Log.e("Failed to show recovery menu: ${e.message}") + 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") + dexFile.isAccessible = true + Log.e("Searching for DevSupport classes in classloader...") + } catch (e: Exception) { + Log.e("Could not search for classes: ${e.message}") + } + } + + 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" } + if (handleReloadJSMethod != null) { + XposedBridge.hookMethod(handleReloadJSMethod, object : XC_MethodReplacement() { + override fun replaceHookedMethod(param: MethodHookParam): Any? { + Log.e("handleReloadJS called - reloading app") + reloadApp() + return null + } + }) + } + } catch (e: Exception) { + Log.e("Failed to hook handleReloadJS: ${e.message}") + } + + try { + val showDevOptionsDialogMethod = clazz.methods.firstOrNull { it.name == "showDevOptionsDialog" } + if (showDevOptionsDialogMethod != null) { + XposedBridge.hookMethod(showDevOptionsDialogMethod, object : XC_MethodReplacement() { + override fun replaceHookedMethod(param: MethodHookParam): Any? { + try { + var activityContext: Context? = null + try { + activityContext = getContextFromDevSupport(clazz, param.thisObject) + if (activityContext != null) { + Log.e("Successfully got context from DevSupport") + } + } 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() + } + return null + } + }) + } + } catch (e: Exception) { + } + } catch (e: Exception) { } } private fun getContextFromDevSupport(clazz: Class<*>, instance: Any?): Context? { + if (instance == null) { + Log.e("getContextFromDevSupport: instance is null") + return null + } + return try { - val mReactInstanceDevHelperField = XposedHelpers.findField(clazz, "mReactInstanceDevHelper") - val helper = mReactInstanceDevHelperField.get(instance) - val getCurrentActivityMethod = helper?.javaClass?.methods?.first { it.name == "getCurrentActivity" } - getCurrentActivityMethod?.invoke(helper) as? Context + + // what if we just did this and searched for literally everything + val helpers = listOf( + "mReactInstanceDevHelper", + "reactInstanceDevHelper", + "mReactInstanceManager", + "mApplicationContext" + ) + + for (helperName in helpers) { + try { + Log.e("Trying field: $helperName") + val helperField = XposedHelpers.findFieldIfExists(clazz, helperName) + if (helperField == null) { + 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" + } + + if (getCurrentActivityMethod != null) { + val ctx = getCurrentActivityMethod.invoke(helper) as? Context + if (ctx != null) { + Log.e("Got context from $helperName.getCurrentActivity()") + return ctx + } + } + } catch (e: Exception) { + Log.e("Error trying $helperName: ${e.message}") + } + } + + Log.e("Could not get context from DevSupport object using any method") + null } catch (e: Exception) { - Log.e("Failed to get context: ${e.message}") + Log.e("Failed to get context (outer catch): ${e.message}") 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") + // Material 3 Design Helper Functions + private fun dpToPx(context: Context, dp: Int): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.toFloat(), + context.resources.displayMetrics + ).toInt() + } - val app = context.applicationContext + private fun isDarkMode(context: Context): Boolean { + return (context.resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_YES + } - if (reactApplicationClass.isInstance(app)) { - val getReactNativeHostMethod = reactApplicationClass.methods.first { it.name == "getReactNativeHost" } - val reactNativeHost = getReactNativeHostMethod.invoke(app) + private fun getM3Colors(context: Context): M3Colors { + val isDark = isDarkMode(context) + return if (isDark) { + M3Colors( + surface = Color.parseColor("#1C1B1F"), + surfaceVariant = Color.parseColor("#49454F"), + onSurface = Color.parseColor("#E6E1E5"), + onSurfaceVariant = Color.parseColor("#CAC4D0"), + primary = Color.parseColor("#D0BCFF"), + onPrimary = Color.parseColor("#381E72"), + primaryContainer = Color.parseColor("#4F378B"), + onPrimaryContainer = Color.parseColor("#EADDFF"), + secondaryContainer = Color.parseColor("#4A4458"), + onSecondaryContainer = Color.parseColor("#E8DEF8"), + error = Color.parseColor("#F2B8B5"), + onError = Color.parseColor("#601410") + ) + } else { + M3Colors( + surface = Color.parseColor("#FFFBFE"), + surfaceVariant = Color.parseColor("#E7E0EC"), + onSurface = Color.parseColor("#1C1B1F"), + onSurfaceVariant = Color.parseColor("#49454F"), + primary = Color.parseColor("#6750A4"), + onPrimary = Color.parseColor("#FFFFFF"), + primaryContainer = Color.parseColor("#EADDFF"), + onPrimaryContainer = Color.parseColor("#21005D"), + secondaryContainer = Color.parseColor("#E8DEF8"), + onSecondaryContainer = Color.parseColor("#1D192B"), + error = Color.parseColor("#B3261E"), + onError = Color.parseColor("#FFFFFF") + ) + } + } - val getReactInstanceManagerMethod = - reactNativeHost.javaClass.methods.first { it.name == "getReactInstanceManager" } - val reactInstanceManager = getReactInstanceManagerMethod.invoke(reactNativeHost) + 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 secondaryContainer: Int, + val onSecondaryContainer: Int, + val error: Int, + val onError: Int + ) - 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") + private fun createM3Background(context: Context, color: Int, cornerRadius: Float): GradientDrawable { + return GradientDrawable().apply { + setColor(color) + setCornerRadius(dpToPx(context, cornerRadius.toInt()).toFloat()) + } + } + + private fun createM3Button(context: Context, text: String, colors: M3Colors, onClick: () -> Unit): View { + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + background = createM3Background(context, colors.primaryContainer, 20f) + setPadding( + dpToPx(context, 24), + dpToPx(context, 10), + dpToPx(context, 24), + dpToPx(context, 10) + ) + clipToOutline = true + isClickable = true + isFocusable = true + + val params = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dpToPx(context, 40) + ).apply { + setMargins(0, dpToPx(context, 8), 0, 0) } - } catch (e: Exception) { - Log.e("Failed to Reload App: ${e.message}") - showError(context, "Failed to reload", "Error: ${e.message}") + layoutParams = params + + addView(TextView(context).apply { + this.text = text + setTextColor(colors.onPrimaryContainer) + textSize = 14f + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + gravity = Gravity.CENTER + }) + + // Create a custom ripple effect that stays within bounds + val rippleDrawable = android.graphics.drawable.RippleDrawable( + ColorStateList.valueOf(Color.argb(30, 255, 255, 255)), + createM3Background(context, colors.primaryContainer, 20f), + null + ) + background = rippleDrawable + + setOnClickListener { onClick() } } } private fun showRecoveryMenu(context: Context) { - val options = arrayOf( - if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode", - "Refetch Bundle", - "Load Custom Bundle", - "Reload App", - "Clear Cache & Reset" - ) + Log.e("showRecoveryMenu called with context: $context") + try { + val colors = getM3Colors(context) + lateinit var dialog: AlertDialog - val adapter = - object : android.widget.ArrayAdapter(context, android.R.layout.select_dialog_item, options) { - override fun getView( - position: Int, - convertView: android.view.View?, - parent: android.view.ViewGroup - ): android.view.View { - val view = super.getView(position, convertView, parent) - (view as? android.widget.TextView)?.apply { - setTextColor(0xFFFFFFFF.toInt()) - textSize = 14f - setPadding( - (16 * context.resources.displayMetrics.density).toInt(), - (12 * context.resources.displayMetrics.density).toInt(), - (16 * context.resources.displayMetrics.density).toInt(), - (12 * context.resources.displayMetrics.density).toInt() - ) - setTypeface(typeface, android.graphics.Typeface.NORMAL) - } - view.setBackgroundColor(0xFF2D2D2D.toInt()) - return view - } - } - - val dialog = AlertDialog.Builder(context) - .setTitle("ShiggyXposed Recovery Menu") - .setAdapter(adapter) { _, which -> - handleMenuSelection(context, which) - } - .setNegativeButton("Close", null) - .create() - - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply { - setTextColor(0xFFBB86FC.toInt()) - setBackgroundColor(0xFF1E1E1E.toInt()) - textSize = 12f + val container = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL setPadding( - (20 * context.resources.displayMetrics.density).toInt(), - (8 * context.resources.displayMetrics.density).toInt(), - (20 * context.resources.displayMetrics.density).toInt(), - (8 * context.resources.displayMetrics.density).toInt() + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24) ) + background = createM3Background(context, colors.surface, 28f) } - dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt())) - - val titleId = context.resources.getIdentifier("alertTitle", "id", "android") - val titleView = dialog.findViewById(titleId) - titleView?.apply { - setTextColor(0xFFBB86FC.toInt()) - textSize = 16f - setTypeface(typeface, android.graphics.Typeface.BOLD) + // Title + val titleView = TextView(context).apply { + text = "ShiggyXposed Recovery" + textSize = 22f + setTextColor(colors.onSurface) + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) + setPadding(0, 0, 0, dpToPx(context, 16)) + gravity = Gravity.CENTER } + container.addView(titleView) - dialog.listView?.setBackgroundColor(0xFF1E1E1E.toInt()) - dialog.listView?.divider = android.graphics.drawable.ColorDrawable(0xFF444444.toInt()) - dialog.listView?.dividerHeight = 1 + // Subtitle + val subtitleView = TextView(context).apply { + text = "Select an option to continue" + textSize = 14f + setTextColor(colors.onSurfaceVariant) + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL) + setPadding(0, 0, 0, dpToPx(context, 24)) + gravity = Gravity.CENTER + } + container.addView(subtitleView) + + // Safe Mode Button + val safeModeText = if (isSafeModeEnabled(context)) "Disable Safe Mode" else "Enable Safe Mode" + container.addView(createM3Button(context, safeModeText, colors) { + dialog.dismiss() + handleMenuSelection(context, 0) + }) + + // Refetch Bundle Button + container.addView(createM3Button(context, "Refetch Bundle", colors) { + dialog.dismiss() + handleMenuSelection(context, 1) + }) + + // Load Custom Bundle Button + container.addView(createM3Button(context, "Load Custom Bundle", colors) { + dialog.dismiss() + handleMenuSelection(context, 2) + }) + + // Reload App Button + container.addView(createM3Button(context, "Reload App", colors) { + dialog.dismiss() + handleMenuSelection(context, 3) + }) + + // Clear Cache & Reset Button + container.addView(createM3Button(context, "Clear Cache & Reset", colors) { + dialog.dismiss() + handleMenuSelection(context, 4) + }) + + dialog = AlertDialog.Builder(context) + .setView(container) + .create() + + dialog.window?.setBackgroundDrawable( + createM3Background(context, Color.TRANSPARENT, 28f) + ) + + dialog.show() + Log.e("Recovery menu shown successfully") + } catch (e: Exception) { + Log.e("Error showing recovery menu: ${e.message}", e) + throw e } - dialog.show() } private fun handleMenuSelection(context: Context, index: Int) { @@ -185,7 +425,7 @@ object LogBoxModule : Module() { 0 -> toggleSafeMode(context) 1 -> showConfirmAction( context, "Refetch Bundle", - "This will download the latest bundle from the official server." + "This will download the latest bundle from Github." ) { refetchBundle(context) } 2 -> showCustomBundleDialog(context) @@ -198,224 +438,89 @@ object LogBoxModule : Module() { } private fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) { - val dialog = AlertDialog.Builder(context) - .setTitle(title) - .setMessage(message) - .setPositiveButton("Confirm") { _, _ -> action() } - .setNegativeButton("Cancel", null) - .create() + val colors = getM3Colors(context) - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply { - setTextColor(0xFFFFFFFF.toInt()) - setBackgroundColor(0xFF1E1E1E.toInt()) - textSize = 12f - } - dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply { - setTextColor(0xFFBB86FC.toInt()) - setBackgroundColor(0xFF1E1E1E.toInt()) - textSize = 12f - setTypeface(typeface, android.graphics.Typeface.BOLD) - } - - dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt())) - - val titleId = context.resources.getIdentifier("alertTitle", "id", "android") - val titleView = dialog.findViewById(titleId) - titleView?.apply { - setTextColor(0xFFBB86FC.toInt()) - textSize = 16f - } - - val messageId = context.resources.getIdentifier("message", "id", "android") - val messageView = dialog.findViewById(messageId) - messageView?.apply { - setTextColor(0xFFFFFFFF.toInt()) - textSize = 14f - setPadding( - (5 * context.resources.displayMetrics.density).toInt(), - paddingTop, - (5 * context.resources.displayMetrics.density).toInt(), - paddingBottom - ) - } - } - dialog.show() - } - - private fun showCustomBundleDialog(context: Context) { - val filesDir = File(context.filesDir, "pyoncord") - val configFile = File(filesDir, "loader.json") - var currentUrl: String? = null - - if (configFile.exists()) { - try { - val json = JSONObject(configFile.readText()) - val customLoadUrl = json.optJSONObject("customLoadUrl") - if (customLoadUrl != null && customLoadUrl.optBoolean("enabled", false)) { - currentUrl = customLoadUrl.optString("url", "") - } - } catch (_: Exception) { - } - } - - val urlInput = EditText(context).apply { - hint = "http://localhost:4040/shiggycord.js" - setTextColor(0xFFFFFFFF.toInt()) - setBackgroundColor(0xFF2D2D2D.toInt()) - setHintTextColor(0xFF888888.toInt()) - inputType = android.text.InputType.TYPE_TEXT_VARIATION_URI - setText(currentUrl ?: "") - gravity = android.view.Gravity.START - textAlignment = android.view.View.TEXT_ALIGNMENT_VIEW_START - textSize = 14f + val container = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL setPadding( - (12 * context.resources.displayMetrics.density).toInt(), - (10 * context.resources.displayMetrics.density).toInt(), - (12 * context.resources.displayMetrics.density).toInt(), - (10 * context.resources.displayMetrics.density).toInt() + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24), + dpToPx(context, 24) + ) + background = createM3Background(context, colors.surface, 28f) + } + + val titleView = TextView(context).apply { + text = title + textSize = 20f + 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)) + lineHeight = dpToPx(context, 20) + } + container.addView(messageView) + + val buttonContainer = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.END + } + + // Cancel button + 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, 16), + dpToPx(context, 10), + dpToPx(context, 16), + dpToPx(context, 10) ) } - val container = android.widget.FrameLayout(context) - val params = android.widget.FrameLayout.LayoutParams( - (context.resources.displayMetrics.widthPixels * 0.85).toInt(), - android.widget.FrameLayout.LayoutParams.WRAP_CONTENT - ) - params.leftMargin = (15 * context.resources.displayMetrics.density).toInt() - params.rightMargin = (15 * context.resources.displayMetrics.density).toInt() - urlInput.layoutParams = params - container.addView(urlInput) + // Confirm button + 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, 16), + dpToPx(context, 10), + dpToPx(context, 16), + dpToPx(context, 10) + ) + } val dialog = AlertDialog.Builder(context) - .setTitle("Custom Bundle URL") .setView(container) - .setPositiveButton("Save", null) - .setNegativeButton("Cancel", null) .create() - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.apply { - setTextColor(0xFFBB86FC.toInt()) - setBackgroundColor(0xFF1E1E1E.toInt()) - textSize = 12f - } - dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply { - setTextColor(0xFFBB86FC.toInt()) - setBackgroundColor(0xFF1E1E1E.toInt()) - textSize = 12f - setTypeface(typeface, android.graphics.Typeface.BOLD) - } - - dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt())) - - val titleId = context.resources.getIdentifier("alertTitle", "id", "android") - val titleView = dialog.findViewById(titleId) - titleView?.apply { - setTextColor(0xFFBB86FC.toInt()) - textSize = 16f - } + 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, 28f) + ) + dialog.show() - - dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { - try { - val url = urlInput.text?.toString()?.trim() ?: "" - if (url.isNotEmpty()) { - setCustomBundleURL(context, url) - dialog.dismiss() - } else { - disableCustomBundle(context) - dialog.dismiss() - } - } catch (e: Exception) { - Toast.makeText(context, "Failed to set custom bundle: ${e.message}", Toast.LENGTH_LONG).show() - } - } - } - - private fun showError(context: Context, title: String, message: String?) { - val dialog = AlertDialog.Builder(context) - .setTitle(title) - .setMessage(message ?: "An unknown error occurred") - .setPositiveButton("Close", null) - .create() - - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.apply { - setTextColor(0xFFBB86FC.toInt()) - setBackgroundColor(0xFF1E1E1E.toInt()) - textSize = 12f - } - - dialog.window?.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(0xFF1E1E1E.toInt())) - - val titleId = context.resources.getIdentifier("alertTitle", "id", "android") - val titleView = dialog.findViewById(titleId) - titleView?.setTextColor(0xFFBB86FC.toInt()) - - val messageId = context.resources.getIdentifier("message", "id", "android") - val messageView = dialog.findViewById(messageId) - messageView?.setTextColor(0xFFFFFFFF.toInt()) - } - dialog.show() - } - - private fun clearCacheAndReset(context: Context) { - scope.launch(Dispatchers.IO) { - try { - val pyoncordDir = getPyoncordDirectory(context) - val bundleFile = File(pyoncordDir, "bundle.js") - val configFile = File(pyoncordDir, "loader.json") - - bundleFile.delete() - - if (configFile.exists()) { - val config = JSONObject(configFile.readText()) - val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() - customLoadUrl.put("enabled", false) - customLoadUrl.put("url", "http://localhost:4040/shiggycord.js") - config.put("customLoadUrl", customLoadUrl) - configFile.writeText(config.toString()) - } - - withContext(Dispatchers.Main) { - Toast.makeText(context, "Cache cleared successfully", Toast.LENGTH_SHORT).show() - reloadApp() - } - - } catch (e: Exception) { - Log.e("Error clearing cache: ${e.message}") - withContext(Dispatchers.Main) { - Toast.makeText(context, "Failed to clear cache", Toast.LENGTH_SHORT).show() - } - } - } - } - - private fun disableCustomBundle(context: Context) { - try { - val configFile = File(getPyoncordDirectory(context), "loader.json") - val config = if (configFile.exists()) { - JSONObject(configFile.readText()) - } else { - JSONObject().apply { - put("loadReactDevTools", false) - } - } - - val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() - customLoadUrl.put("enabled", false) - customLoadUrl.put("url", "http://localhost:4040/shiggycord.js") - config.put("customLoadUrl", customLoadUrl) - - configFile.writeText(config.toString()) - Toast.makeText(context, "Custom bundle disabled", Toast.LENGTH_SHORT).show() - reloadApp() - } catch (e: Exception) { - Toast.makeText(context, "Failed to disable custom bundle", Toast.LENGTH_SHORT).show() - } } private fun isSafeModeEnabled(context: Context): Boolean { @@ -462,8 +567,7 @@ object LogBoxModule : Module() { settings.put("safeMode", safeMode) settingsFile.writeText(settings.toString()) - Toast.makeText(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}", Toast.LENGTH_SHORT).show() - + showM3Toast(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}") reloadApp() } catch (e: Exception) { @@ -472,6 +576,10 @@ object LogBoxModule : Module() { } } + private fun showM3Toast(context: Context, message: String) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + private fun refetchBundle(context: Context) { try { val pyoncordDir = getPyoncordDirectory(context) @@ -492,72 +600,307 @@ object LogBoxModule : Module() { } } - private fun resetBundle(context: Context) { - try { - val pyoncordDir = getPyoncordDirectory(context) - val bundleFile = File(pyoncordDir, "bundle.js") - val configFile = File(pyoncordDir, "loader_config.json") - - bundleFile.delete() - - if (configFile.exists()) { - val config = JSONObject(configFile.readText()) - config.put("customLoadUrlEnabled", false) - config.put("customLoadUrl", "http://localhost:4040/ShiggyCord.js") - configFile.writeText(config.toString()) - } - - reloadApp() - } catch (e: Exception) { - Log.e("Error resetting bundle: ${e.message}") - showError(context, "Failed to reset bundle", e.message) - } - } - - private fun validateAndLoadBundle(context: Context, urlString: String) { + private fun clearCacheAndReset(context: Context) { 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 pyoncordDir = getPyoncordDirectory(context) + val bundleFile = File(pyoncordDir, "bundle.js") + val configFile = File(pyoncordDir, "loader.json") - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connectTimeout = 10000 - connection.readTimeout = 10000 + bundleFile.delete() - 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 + if (configFile.exists()) { + val config = JSONObject(configFile.readText()) + val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() + customLoadUrl.put("enabled", false) + customLoadUrl.put("url", "http://localhost:4040/shiggycord.js") + config.put("customLoadUrl", customLoadUrl) + configFile.writeText(config.toString()) } withContext(Dispatchers.Main) { - setCustomBundleURL(context, urlString) + Toast.makeText(context, "Cache cleared successfully", Toast.LENGTH_SHORT).show() + reloadApp() } + } catch (e: Exception) { + Log.e("Error clearing cache: ${e.message}") withContext(Dispatchers.Main) { - showError(context, "Connection Error", "Could not reach URL: ${e.message}") + Toast.makeText(context, "Failed to clear cache", Toast.LENGTH_SHORT).show() } } } } - private fun setCustomBundleURL(context: Context, url: String) { + private fun showCustomBundleDialog(context: Context) { + val colors = getM3Colors(context) + val filesDir = File(context.filesDir, "pyoncord") + val configFile = File(filesDir, "loader.json") + var currentUrl: String? = null + var isEnabled = false + + if (configFile.exists()) { + try { + val json = JSONObject(configFile.readText()) + val customLoadUrl = json.optJSONObject("customLoadUrl") + if (customLoadUrl != null) { + isEnabled = customLoadUrl.optBoolean("enabled", false) + currentUrl = customLoadUrl.optString("url", "") + } + } 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, 28f) + } + + val titleView = TextView(context).apply { + text = "Custom Bundle URL" + textSize = 20f + setTextColor(colors.onSurface) + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD) + setPadding(0, 0, 0, dpToPx(context, 16)) + } + container.addView(titleView) + + // Toggle switch container + val toggleContainer = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + val params = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.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, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f + ) + } + + val toggleSwitch = createM3Switch(context, colors, isEnabled) + + toggleContainer.addView(toggleLabel) + toggleContainer.addView(toggleSwitch) + container.addView(toggleContainer) + + val urlInput = EditText(context).apply { + hint = "http://localhost:4040/shiggycord.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) + ) + isEnabled + alpha = if (isEnabled) 1f else 0.5f + val params = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(0, 0, 0, dpToPx(context, 24)) + } + layoutParams = params + } + container.addView(urlInput) + + toggleSwitch.setOnClickListener { + val currentState = toggleSwitch.tag as Boolean + val newState = !currentState + toggleSwitch.tag = newState + updateSwitchAppearance(context, toggleSwitch, colors, newState) + + urlInput.animate() + .alpha(if (newState) 1f else 0.5f) + .setDuration(250) + .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, 16), + dpToPx(context, 10), + dpToPx(context, 16), + dpToPx(context, 10) + ) + } + + 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, 16), + dpToPx(context, 10), + dpToPx(context, 16), + dpToPx(context, 10) + ) + } + + val dialog = AlertDialog.Builder(context) + .setView(container) + .create() + + cancelButton.setOnClickListener { dialog.dismiss() } + saveButton.setOnClickListener { + try { + val url = urlInput.text?.toString()?.trim() ?: "" + val enabled = toggleSwitch.tag as Boolean + + if (enabled && url.isNotEmpty()) { + setCustomBundleURL(context, url, true) + dialog.dismiss() + } else if (!enabled) { + setCustomBundleURL(context, url.ifEmpty { "http://localhost:4040/shiggycord.js" }, false) + dialog.dismiss() + } else { + Toast.makeText(context, "Please enter a URL", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(context, "Failed to set custom bundle: ${e.message}", Toast.LENGTH_LONG).show() + } + } + + buttonContainer.addView(cancelButton) + buttonContainer.addView(saveButton) + container.addView(buttonContainer) + + dialog.window?.setBackgroundDrawable( + createM3Background(context, Color.TRANSPARENT, 28f) + ) + + dialog.show() + } + + private fun createM3Switch(context: Context, colors: M3Colors, isChecked: Boolean): View { + val switchView = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + tag = isChecked + layoutParams = LinearLayout.LayoutParams( + dpToPx(context, 52), + dpToPx(context, 32) + ) + } + + updateSwitchAppearance(context, switchView, colors, isChecked) + return switchView + } + + private fun updateSwitchAppearance(context: Context, switchView: View, colors: M3Colors, isChecked: Boolean) { + val trackColor = if (isChecked) colors.primary else colors.surfaceVariant + val thumbColor = if (isChecked) colors.onPrimary else colors.onSurfaceVariant + + val currentTrackColor = if (switchView.background is GradientDrawable) { + (switchView.background as GradientDrawable).color?.defaultColor + ?: (if (!isChecked) colors.primary else colors.surfaceVariant) + } else { + if (!isChecked) colors.primary else colors.surfaceVariant + } + + val trackColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentTrackColor, trackColor) + trackColorAnimator.duration = 250 + trackColorAnimator.interpolator = DecelerateInterpolator() + trackColorAnimator.addUpdateListener { animator -> + val animatedColor = animator.animatedValue as Int + val track = GradientDrawable().apply { + setColor(animatedColor) + setCornerRadius(dpToPx(context, 16).toFloat()) + } + switchView.background = track + } + + if (switchView is LinearLayout) { + val thumb = if (switchView.childCount > 0) { + switchView.getChildAt(0) + } else { + // Create new thumb if none exists + View(context).apply { + val size = dpToPx(context, 24) + layoutParams = LinearLayout.LayoutParams(size, size).apply { + marginStart = dpToPx(context, 4) + marginEnd = dpToPx(context, 24) + } + switchView.addView(this) + } + } + + val currentParams = thumb.layoutParams as LinearLayout.LayoutParams + val targetMarginStart = if (isChecked) dpToPx(context, 24) else dpToPx(context, 4) + + val marginAnimator = ValueAnimator.ofInt(currentParams.marginStart, targetMarginStart) + marginAnimator.duration = 250 + marginAnimator.interpolator = DecelerateInterpolator() + marginAnimator.addUpdateListener { animator -> + val animatedMargin = animator.animatedValue as Int + val params = thumb.layoutParams as LinearLayout.LayoutParams + params.marginStart = animatedMargin + params.marginEnd = dpToPx(context, 28) - animatedMargin + thumb.layoutParams = params + } + + val currentThumbColor = if (thumb.background is GradientDrawable) { + (thumb.background as GradientDrawable).color?.defaultColor + ?: (if (!isChecked) colors.onPrimary else colors.onSurfaceVariant) + } else { + if (!isChecked) colors.onPrimary else colors.onSurfaceVariant + } + + val thumbColorAnimator = ValueAnimator.ofObject(ArgbEvaluator(), currentThumbColor, thumbColor) + thumbColorAnimator.duration = 250 + thumbColorAnimator.interpolator = DecelerateInterpolator() + thumbColorAnimator.addUpdateListener { animator -> + val animatedColor = animator.animatedValue as Int + thumb.background = GradientDrawable().apply { + setColor(animatedColor) + shape = GradientDrawable.OVAL + } + } + + val animatorSet = AnimatorSet() + animatorSet.playTogether(trackColorAnimator, marginAnimator, thumbColorAnimator) + animatorSet.start() + } + } + + private fun setCustomBundleURL(context: Context, url: String, enabled: Boolean) { try { val pyoncordDir = getPyoncordDirectory(context) val configFile = File(pyoncordDir, "loader.json") @@ -571,15 +914,18 @@ object LogBoxModule : Module() { } val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() - customLoadUrl.put("enabled", true) + customLoadUrl.put("enabled", enabled) customLoadUrl.put("url", url) config.put("customLoadUrl", customLoadUrl) configFile.writeText(config.toString()) - File(pyoncordDir, "bundle.js").delete() - - Toast.makeText(context, "Custom bundle set", Toast.LENGTH_SHORT).show() + if (enabled) { + File(pyoncordDir, "bundle.js").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) { @@ -588,6 +934,53 @@ object LogBoxModule : Module() { } } + private fun disableCustomBundle(context: Context) { + try { + val configFile = File(getPyoncordDirectory(context), "loader.json") + val config = if (configFile.exists()) { + JSONObject(configFile.readText()) + } else { + JSONObject().apply { + put("loadReactDevTools", false) + } + } + + val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() + customLoadUrl.put("enabled", false) + customLoadUrl.put("url", "http://localhost:4040/shiggycord.js") + config.put("customLoadUrl", customLoadUrl) + + configFile.writeText(config.toString()) + Toast.makeText(context, "Custom bundle disabled", Toast.LENGTH_SHORT).show() + reloadApp() + } catch (e: Exception) { + Toast.makeText(context, "Failed to disable custom bundle", Toast.LENGTH_SHORT).show() + } + } + + private fun resetBundle(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()) + } + + reloadApp() + } catch (e: Exception) { + Log.e("Error resetting bundle: ${e.message}") + showError(context, "Failed to reset bundle", e.message) + } + } + private fun getPyoncordDirectory(context: Context): File { val dir = File(context.filesDir, "pyoncord") if (!dir.exists()) { @@ -596,6 +989,63 @@ object LogBoxModule : Module() { return dir } - override fun onContext(context: Context) { + private fun showError(context: Context, title: String, message: String?) { + val colors = getM3Colors(context) + + 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, 28f) + } + + val titleView = TextView(context).apply { + text = title + textSize = 20f + 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)) + lineHeight = dpToPx(context, 20) + } + 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, 16), + dpToPx(context, 10), + dpToPx(context, 16), + dpToPx(context, 10) + ) + } + + val dialog = AlertDialog.Builder(context) + .setView(container) + .create() + + okButton.setOnClickListener { dialog.dismiss() } + container.addView(okButton) + + dialog.window?.setBackgroundDrawable( + createM3Background(context, Color.TRANSPARENT, 28f) + ) + + dialog.show() } } diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt index eb4f668..caf9157 100644 --- a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt @@ -142,6 +142,9 @@ object UpdaterModule : Module() { fun showErrorDialog(e: Throwable) { val activity = lastActivity?.get() ?: return + + activity.runOnUiThread { + } } fun resetLoaderConfig(context: Context) { diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt index a808a86..2e084c3 100644 --- a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt @@ -13,16 +13,8 @@ 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 - } - } + + // This only exists on 30720x and above runCatching { hookMethod("isDisabled") { before { @@ -30,17 +22,29 @@ object BlockCrashReportingModule : Module() { result = true } } - } - - val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider") - sentryInitProviderClass?.hookMethod("onCreate") { - before { - Log.i("Blocked SentryInitProvider initialization") - result = true + }.onFailure { + // In older versions, this hook works fine. + // Hooking this on 30720x will result in a crash after a few seconds, + // since Discord asserts initialization when setting a Sentry tag. + hookMethod( + "init", Context::class.java, String::class.java + ) { + before { + Log.i("Blocked CrashReporting initialization") + result = null + } } } - - return@with } + + val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider") + sentryInitProviderClass?.hookMethod("onCreate") { + before { + Log.i("Blocked SentryInitProvider initialization") + result = true + } + } + + return@with } }