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/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e3fba9..a2cc3d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,15 +5,15 @@ plugins { } android { - namespace = "GoonXposed.xposed" + namespace = "ShiggyXposed.xposed" compileSdk = 36 defaultConfig { - applicationId = "GoonXposed.xposed" + applicationId = "ShiggyXposed.xposed" minSdk = 24 targetSdk = 36 - versionCode = 1210 - versionName = "1.2.1" + versionCode = 1220 + versionName = "1.2.2" } buildTypes { @@ -28,33 +28,24 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true } - kotlinOptions { jvmTarget = "17" } - buildFeatures { buildConfig = true } -} - -repositories { - mavenCentral() - google() - maven("https://jitpack.io") } dependencies { compileOnly("de.robv.android.xposed:api:82") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") - implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0") - implementation("io.ktor:ktor-client-core:3.3.0") - implementation("io.ktor:ktor-client-cio:3.3.0") - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.11.0") - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - -implementation("com.google.android.material:material:1.12.0") + implementation("androidx.core:core-ktx:1.17.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("io.ktor:ktor-client-core:3.3.1") + implementation("io.ktor:ktor-client-cio:3.3.1") + implementation("com.google.android.material:material:1.12.0") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 877619f..40f8598 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,8 +1,9 @@ + xmlns:tools="http://schemas.android.com/tools" +> + diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Constants.kt similarity index 67% rename from app/src/main/kotlin/io/github/revenge/xposed/Constants.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/Constants.kt index 9562299..7d306ab 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/Constants.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Constants.kt @@ -1,4 +1,4 @@ -package GoonXposed.xposed +package ShiggyXposed.xposed class Constants { companion object { @@ -9,10 +9,11 @@ class Constants { const val CACHE_DIR = "cache/pyoncord" const val MAIN_SCRIPT_FILE = "bundle.js" - const val LOG_TAG = "GoonXposed" - const val LOADER_NAME = "GoonXposed" + const val LOG_TAG = "Shiggy" - const val USER_AGENT = "GoonXposed" + const val LOADER_NAME = "ShiggyXposed" + + const val USER_AGENT = "ShiggyXposed" } } diff --git a/app/src/main/kotlin/io/github/revenge/xposed/Main.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt similarity index 83% rename from app/src/main/kotlin/io/github/revenge/xposed/Main.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt index 6c14416..9aba2dd 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/Main.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Main.kt @@ -1,4 +1,4 @@ -package GoonXposed.xposed +package ShiggyXposed.xposed import android.app.Activity import android.content.Context @@ -7,15 +7,15 @@ import android.os.Bundle import de.robv.android.xposed.IXposedHookLoadPackage import de.robv.android.xposed.IXposedHookZygoteInit import de.robv.android.xposed.callbacks.XC_LoadPackage -import GoonXposed.xposed.Utils.Log -import GoonXposed.xposed.modules.* -import GoonXposed.xposed.modules.appearance.FontsModule -import GoonXposed.xposed.modules.appearance.SysColorsModule -import GoonXposed.xposed.modules.appearance.ThemesModule -import GoonXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule -import GoonXposed.xposed.modules.bridge.BridgeModule -import GoonXposed.xposed.modules.no_track.BlockCrashReportingModule -import GoonXposed.xposed.modules.no_track.BlockDeepLinksTrackingModule +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.* +import ShiggyXposed.xposed.modules.appearance.FontsModule +import ShiggyXposed.xposed.modules.appearance.SysColorsModule +import ShiggyXposed.xposed.modules.appearance.ThemesModule +import ShiggyXposed.xposed.modules.bridge.AdditionalBridgeMethodsModule +import ShiggyXposed.xposed.modules.bridge.BridgeModule +import ShiggyXposed.xposed.modules.no_track.BlockCrashReportingModule +import ShiggyXposed.xposed.modules.no_track.BlockDeepLinksTrackingModule import kotlinx.coroutines.CompletableDeferred object HookStateHolder { @@ -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/io/github/revenge/xposed/Module.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Module.kt similarity index 51% rename from app/src/main/kotlin/io/github/revenge/xposed/Module.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/Module.kt index 52af174..41f878f 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/Module.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Module.kt @@ -7,25 +7,28 @@ import de.robv.android.xposed.IXposedHookZygoteInit import de.robv.android.xposed.XC_MethodHook import de.robv.android.xposed.XposedBridge import de.robv.android.xposed.callbacks.XC_LoadPackage +import kotlinx.serialization.json.JsonObjectBuilder import java.io.File import java.lang.reflect.Method -import kotlinx.serialization.json.JsonObjectBuilder data class AppInfo( - val name: String, - val packageName: String, - val version: String, - val versionCode: Long, + val name: String, + val packageName: String, + val version: String, + val versionCode: Long, ) abstract class Module { - /** Builds a JSON payload to be injected into the JavaScript context. */ - @Deprecated( - "This will be removed in future versions. Payloads can be replaced via synchronous bridge methods." - ) - open fun buildPayload(builder: JsonObjectBuilder) {} + /** + * Builds a JSON payload to be injected into the JavaScript context. + */ + @Deprecated("This will be removed in future versions. Payloads can be replaced via synchronous bridge methods.") + open fun buildPayload(builder: JsonObjectBuilder) { + } - /** Called during Zygote initialization. */ + /** + * Called during Zygote initialization. + */ open fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) {} /** @@ -38,11 +41,10 @@ abstract class Module { open fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) {} /** - * Called after [Context] has been attached to a [android.content.ContextWrapper] via - * [android.content.ContextWrapper.attachBaseContext]. + * Called after [Context] has been attached to a [android.content.ContextWrapper] + * via [android.content.ContextWrapper.attachBaseContext]. * - * This may be called multiple times for different [Context]s, e.g. when the app is killed by - * the system. + * This may be called multiple times for different [Context]s, e.g. when the app is killed by the system. * * For bridge methods that require access to [Context], register them here. */ @@ -55,25 +57,22 @@ abstract class Module { * * Bridge methods must not be registered here, as it can be too late and cause crashes. * - * If you need to register bridge methods that require an [Activity], do it in [onContext] and - * make it async. + * If you need to register bridge methods that require an [Activity], do it in [onContext] and make it async. * - * Once an [Activity] is available, execute the queued calls. Then from JS, call the method - * asynchronously. + * Once an [Activity] is available, execute the queued calls. Then from JS, call the method asynchronously. */ open fun onActivity(activity: Activity) {} protected fun Context.getAppInfo(): AppInfo { val pInfo = packageManager.getPackageInfo(packageName, 0) - val versionCode = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode - else @Suppress("DEPRECATION") pInfo.versionCode.toLong() + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) pInfo.longVersionCode + else @Suppress("DEPRECATION") pInfo.versionCode.toLong() return AppInfo( - packageManager.getApplicationLabel(applicationInfo).toString(), - packageName, - pInfo.versionName ?: versionCode.toString(), - versionCode, + packageManager.getApplicationLabel(applicationInfo).toString(), + packageName, + pInfo.versionName ?: versionCode.toString(), + versionCode, ) } @@ -86,22 +85,21 @@ abstract class Module { if (!this.isFile) this.deleteRecursively() } - protected fun Class<*>.method(name: String, vararg params: Class<*>?): Method = - getDeclaredMethod(name, *params).apply { isAccessible = true } + protected fun Class<*>.method( + name: String, vararg params: Class<*>? + ): Method = getDeclaredMethod(name, *params).apply { + isAccessible = true + } - protected fun ClassLoader.safeLoadClass(name: String): Class<*>? = - runCatching { loadClass(name) }.getOrNull() + protected fun ClassLoader.safeLoadClass(name: String): Class<*>? = runCatching { loadClass(name) }.getOrNull() - protected fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook = - XposedBridge.hookMethod(this, hook) + protected fun Method.hook(hook: XC_MethodHook): XC_MethodHook.Unhook = XposedBridge.hookMethod(this, hook) protected fun Method.hook(block: MethodHookBuilder.() -> Unit): XC_MethodHook.Unhook = - hook(MethodHookBuilder().apply(block).build()) + hook(MethodHookBuilder().apply(block).build()) protected fun Class<*>.hookMethod( - name: String, - vararg params: Class<*>?, - block: MethodHookBuilder.() -> Unit + name: String, vararg params: Class<*>?, block: MethodHookBuilder.() -> Unit ): XC_MethodHook.Unhook = method(name, *params).hook(MethodHookBuilder().apply(block).build()) protected class MethodHookBuilder { @@ -116,36 +114,33 @@ abstract class Module { afterBlock = block } - fun build(): XC_MethodHook = - object : XC_MethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - val b = beforeBlock - if (b != null) { - val scope = - HookScope( - param = param, - proceed = { p -> super.beforeHookedMethod(p) } - ) - scope.b() - } else { - super.beforeHookedMethod(param) - } - } - - override fun afterHookedMethod(param: MethodHookParam) { - val a = afterBlock - if (a != null) { - val scope = - HookScope( - param = param, - proceed = { p -> super.afterHookedMethod(p) } - ) - scope.a() - } else { - super.afterHookedMethod(param) - } - } + fun build(): XC_MethodHook = object : XC_MethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val b = beforeBlock + if (b != null) { + val scope = HookScope( + param = param, proceed = { p -> + super.beforeHookedMethod(p) + }) + scope.b() + } else { + super.beforeHookedMethod(param) } + } + + override fun afterHookedMethod(param: MethodHookParam) { + val a = afterBlock + if (a != null) { + val scope = HookScope( + param = param, proceed = { p -> + super.afterHookedMethod(p) + }) + scope.a() + } else { + super.afterHookedMethod(param) + } + } + } } /** @@ -159,23 +154,19 @@ abstract class Module { * @property param The [XC_MethodHook.MethodHookParam] for the current hook. * @property proceed Function that calls the super method. */ - protected class HookScope - internal constructor( - val param: XC_MethodHook.MethodHookParam, - private val proceed: (XC_MethodHook.MethodHookParam) -> Unit + protected class HookScope internal constructor( + val param: XC_MethodHook.MethodHookParam, private val proceed: (XC_MethodHook.MethodHookParam) -> Unit ) { /** - * Continues with the default XC_MethodHook super behavior. Equivalent to calling - * `super.beforeHookedMethod(param)` or `super.afterHookedMethod(param)` depending on the - * phase. + * Continues with the default XC_MethodHook super behavior. + * Equivalent to calling `super.beforeHookedMethod(param)` or + * `super.afterHookedMethod(param)` depending on the phase. */ fun proceed() = proceed(param) - val thisObject: Any? - get() = param.thisObject + val thisObject: Any? get() = param.thisObject - val args: Array - get() = param.args + val args: Array get() = param.args var result: Any? get() = param.result diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt new file mode 100644 index 0000000..8957912 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt @@ -0,0 +1,58 @@ +package ShiggyXposed.xposed + +import android.app.AlertDialog +import android.app.AndroidAppHelper +import android.content.Context +import android.content.Intent +import ShiggyXposed.xposed.modules.UpdaterModule +import kotlinx.serialization.json.Json +import java.io.File +import kotlin.system.exitProcess + +class Utils { + companion object { + val JSON = Json { ignoreUnknownKeys = true } + + fun reloadApp() { + val application = AndroidAppHelper.currentApplication() + val intent = application.packageManager.getLaunchIntentForPackage(application.packageName) + application.startActivity(Intent.makeRestartActivityTask(intent!!.component)) + exitProcess(0) + } + + fun showRecoveryAlert(context: Context) { + AlertDialog.Builder(context).setTitle("ShiggyCord Recovery Options") + .setItems(arrayOf("Reload", "Delete Script", "Reset Loader Config")) { _, which -> + when (which) { + 0 -> { + reloadApp() + } + + 1 -> { + val bundleFile = File( + context.dataDir, "${Constants.CACHE_DIR}/${Constants.MAIN_SCRIPT_FILE}" + ) + + if (bundleFile.exists()) bundleFile.delete() + + reloadApp() + } + + 2 -> { + UpdaterModule.resetLoaderConfig(context) + reloadApp() + } + } + }.show() + } + } + + object Log { + fun e(msg: String) = android.util.Log.e(Constants.LOG_TAG, msg) + fun e(msg: String, throwable: Throwable) = android.util.Log.e(Constants.LOG_TAG, msg, throwable) + fun i(msg: String) = android.util.Log.i(Constants.LOG_TAG, msg) + fun i(msg: String, throwable: Throwable) = android.util.Log.i(Constants.LOG_TAG, msg, throwable) + fun w(msg: String) = android.util.Log.w(Constants.LOG_TAG, msg) + fun w(msg: String, throwable: Throwable) = android.util.Log.w(Constants.LOG_TAG, msg, throwable) + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/CacheModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/CacheModule.kt similarity index 98% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/CacheModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/CacheModule.kt index debbbc8..01b4971 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/CacheModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/CacheModule.kt @@ -1,10 +1,10 @@ -package GoonXposed.xposed.modules +package ShiggyXposed.xposed.modules import android.content.Context import android.util.AtomicFile -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Log -import GoonXposed.xposed.modules.bridge.BridgeModule +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.bridge.BridgeModule import java.io.* /** diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/FixResourcesModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/FixResourcesModule.kt new file mode 100644 index 0000000..15cfbf5 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/FixResourcesModule.kt @@ -0,0 +1,23 @@ +package ShiggyXposed.xposed.modules + +import android.content.res.Resources +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Constants.Companion.TARGET_PACKAGE +import ShiggyXposed.xposed.Module + +/** + * Hooks [Resources.getIdentifier] to fix resource package name mismatch. + */ +object FixResourcesModule : Module() { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + if (packageName != TARGET_PACKAGE) { + Resources::class.java.hookMethod( + "getIdentifier", String::class.java, String::class.java, String::class.java + ) { + before { + if (args[2] == packageName) args[2] = TARGET_PACKAGE + } + } + } + } +} diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/HookScriptLoaderModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/HookScriptLoaderModule.kt new file mode 100644 index 0000000..0a0dd84 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/HookScriptLoaderModule.kt @@ -0,0 +1,121 @@ +package ShiggyXposed.xposed.modules + +import android.content.res.AssetManager +import android.content.res.XModuleResources +import de.robv.android.xposed.IXposedHookZygoteInit +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.HookStateHolder +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import java.io.File +import java.lang.reflect.Method + +/** + * Hooks React Native's script loading methods to load custom scripts and bundles. + * + * Preload scripts should be placed in the [PRELOADS_DIR] directory inside the module's files directory. + * + * The main bundle should be placed in the [Constants.CACHE_DIR] directory named [Constants.MAIN_SCRIPT_FILE]. + * If the bundle file does not exist, it will attempt to load `assets://Shiggy.bundle` from the module's assets. + */ +object HookScriptLoaderModule : Module() { + private lateinit var preloadsDir: File + private lateinit var mainScript: File + + private lateinit var modulePath: String + private lateinit var resources: XModuleResources + + const val PRELOADS_DIR = "preloads" + + override fun onInit(startupParam: IXposedHookZygoteInit.StartupParam) { + this@HookScriptLoaderModule.modulePath = startupParam.modulePath + } + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { asDir() } + val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { asDir() } + + preloadsDir = File(filesDir, PRELOADS_DIR).apply { asDir() } + mainScript = File(cacheDir, Constants.MAIN_SCRIPT_FILE).apply { asFile() } + + listOf( + "com.facebook.react.runtime.ReactInstance\$loadJSBundle$1", + "com.facebook.react.runtime.ReactInstance$1", + // TODO: Remove once Discord fully switches to Bridgeless + "com.facebook.react.bridge.CatalystInstanceImpl" + ).mapNotNull { classLoader.safeLoadClass(it) }.forEach { hook(it) } + } + + private fun hook(instance: Class<*>) = runCatching { + val loadScriptFromAssets = instance.method( + "loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType + ) + + val loadScriptFromFile = instance.method( + "loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType + ) + + loadScriptFromAssets.hook { + before { + Log.i("Received call to loadScriptFromAssets: ${args[1]} (sync: ${args[2]})") + runCustomScripts(loadScriptFromFile, loadScriptFromAssets) + } + } + + loadScriptFromFile.hook { + before { + Log.i("Received call to loadScriptFromFile: ${args[0]} (sync: ${args[2]})") + runCustomScripts(loadScriptFromFile, loadScriptFromAssets) + } + } + }.onFailure { + Log.e("Failed to hook script loading methods in ${instance.name}:", it) + } + + private fun HookScope.runCustomScripts(loadScriptFromFile: Method, loadScriptFromAssets: Method) { + Log.i("Running custom scripts...") + + runBlocking { + val ready = async { HookStateHolder.readyDeferred.join() } + val download = async { UpdaterModule.downloadScript().join() } + + awaitAll(ready, download) + } + + val loadSynchronously = args[2] + val runScriptFile = { file: File -> + Log.i("Loading script: ${file.absolutePath}") + + XposedBridge.invokeOriginalMethod( + loadScriptFromFile, thisObject, arrayOf(file.absolutePath, file.absolutePath, loadSynchronously) + ) + + Unit + } + + try { + preloadsDir.walk().filter { it.isFile }.forEach(runScriptFile) + + if (mainScript.exists()) runScriptFile(mainScript) + else { + Log.i("Main script does not exist, falling back") + + if (!::resources.isInitialized) resources = XModuleResources.createInstance(modulePath, null) + + XposedBridge.invokeOriginalMethod( + loadScriptFromAssets, + thisObject, + arrayOf(resources.assets, "assets://Shiggy.bundle", loadSynchronously) + ) + } + } catch (e: Throwable) { + Log.e("Unable to run scripts:", e) + } + } +} diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt new file mode 100644 index 0000000..ad3f9ce --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/LogBoxModule.kt @@ -0,0 +1,1051 @@ +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.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 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.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +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 + + try { + val dcdReactNativeHostClass = classLoader.loadClass("com.discord.bridge.DCDReactNativeHost") + val getUseDeveloperSupportMethod = + dcdReactNativeHostClass.methods.first { it.name == "getUseDeveloperSupport" } + + getUseDeveloperSupportMethod.hook { + before { + result = true + } + } + Log.e("Successfully hooked DCDReactNativeHost") + } catch (e: Exception) { + Log.e("Failed to hook DCDReactNativeHost: ${e.message}") + } + + return@with + } + + override fun onContext(context: Context) { + try { + Log.e("onContext called with context: $context") + contextForMenu = context + + 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 clazz = packageParam.classLoader.loadClass(className) + Log.e("Found class: $className") + hookDevSupportManager(clazz, context) + foundAny = true + } catch (e: Exception) { + 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 { + + // 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 (outer catch): ${e.message}") + null + } + } + + // 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() + } + + 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 + } + + 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") + ) + } + } + + 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 + ) + + 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) + } + 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) { + Log.e("showRecoveryMenu called with context: $context") + try { + val colors = getM3Colors(context) + 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, 28f) + } + + // 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) + + // 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 + } + } + + private fun handleMenuSelection(context: Context, index: Int) { + when (index) { + 0 -> toggleSafeMode(context) + 1 -> showConfirmAction( + context, "Refetch Bundle", + "This will download the latest bundle from Github." + ) { refetchBundle(context) } + + 2 -> showCustomBundleDialog(context) + 3 -> reloadApp() + 4 -> showConfirmAction( + context, "Clear Cache & Reset", + "This will clear all cached bundles and reset to default settings." + ) { clearCacheAndReset(context) } + } + } + + private fun showConfirmAction(context: Context, title: String, message: String, action: () -> Unit) { + 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.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) + ) + } + + // 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) + .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, 28f) + ) + + dialog.show() + } + + private fun isSafeModeEnabled(context: Context): Boolean { + return try { + val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS") + if (!settingsFile.exists()) return false + + val json = JSONObject(settingsFile.readText()) + json.optJSONObject("safeMode")?.optBoolean("enabled", false) ?: false + } catch (e: Exception) { + Log.e("Error checking safe mode: ${e.message}") + false + } + } + + private fun toggleSafeMode(context: Context) { + try { + val settingsFile = File(context.filesDir, "vd_mmkv/VENDETTA_SETTINGS") + val themeFile = File(context.filesDir, "vd_mmkv/VENDETTA_THEMES") + + settingsFile.parentFile?.mkdirs() + + val settings = if (settingsFile.exists()) { + JSONObject(settingsFile.readText()) + } else { + JSONObject() + } + + val safeMode = settings.optJSONObject("safeMode") ?: JSONObject() + val currentState = safeMode.optBoolean("enabled", false) + val newState = !currentState + + safeMode.put("enabled", newState) + + if (newState && themeFile.exists()) { + val theme = JSONObject(themeFile.readText()) + val themeId = theme.optString("id") + if (themeId.isNotEmpty()) { + safeMode.put("currentThemeId", themeId) + themeFile.delete() + } + } + + settings.put("safeMode", safeMode) + settingsFile.writeText(settings.toString()) + + showM3Toast(context, "Safe Mode ${if (newState) "Enabled" else "Disabled"}") + reloadApp() + + } catch (e: Exception) { + Log.e("Error toggling safe mode: ${e.message}") + showError(context, "Failed to toggle safe mode", e.message) + } + } + + 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) + val bundleFile = File(pyoncordDir, "bundle.js") + val backupFile = File(pyoncordDir, "bundle.js.backup") + + if (bundleFile.exists()) { + backupFile.delete() + bundleFile.renameTo(backupFile) + Log.e("Bundle moved to backup") + } + + reloadApp() + + } catch (e: Exception) { + Log.e("Error refetching bundle: ${e.message}") + showError(context, "Failed to refetch bundle", e.message) + } + } + + private fun 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 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") + + val config = if (configFile.exists()) { + JSONObject(configFile.readText()) + } else { + JSONObject().apply { + put("loadReactDevTools", false) + } + } + + val customLoadUrl = config.optJSONObject("customLoadUrl") ?: JSONObject() + customLoadUrl.put("enabled", enabled) + customLoadUrl.put("url", url) + config.put("customLoadUrl", customLoadUrl) + + configFile.writeText(config.toString()) + + 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) { + Log.e("Error setting custom bundle URL: ${e.message}") + showError(context, "Failed to save configuration", e.message) + } + } + + 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()) { + dir.mkdirs() + } + return dir + } + + 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/PayloadGlobalModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PayloadGlobalModule.kt new file mode 100644 index 0000000..956cfc2 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PayloadGlobalModule.kt @@ -0,0 +1,80 @@ +package ShiggyXposed.xposed.modules + +import android.content.res.AssetManager +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.modules.HookScriptLoaderModule.PRELOADS_DIR +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.File + +// TODO: Remove this redundant module and merge its functionality into AdditionalBridgeMethodsModule if needed +class PayloadGlobalModule(private val modules: List) : Module() { + private companion object { + const val GLOBAL_NAME = "__PYON_LOADER__" + } + + private fun getPayloadString(): String = JSON.encodeToString( + buildJsonObject { + put("loaderName", Constants.LOADER_NAME) + put("loaderVersion", BuildConfig.VERSION_NAME) + @Suppress("DEPRECATION") for (module in modules) module.buildPayload(this) + }) + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val catalystInstance = classLoader.safeLoadClass("com.facebook.react.bridge.CatalystInstanceImpl") + val scriptLoader = classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance\$loadJSBundle$1") + ?: classLoader.safeLoadClass("com.facebook.react.runtime.ReactInstance$1") + + val setGlobalVariable: (XC_MethodHook.MethodHookParam, String, String) -> Unit = { param, key, json -> + runCatching { + // Attempt old CatalystInstanceImpl method + catalystInstance!!.method( + "setGlobalVariable", String::class.java, String::class.java + ).invoke(param.thisObject, key, json) + }.onFailure { + // Failed (likely because it's a stub), use a workaround + val preloadsDir = File("${appInfo.dataDir}/${Constants.FILES_DIR}", PRELOADS_DIR) + File(preloadsDir, "rv_globals_$key.js").apply { + writeText("this[${JSON.encodeToString(key)}]=$json") + + XposedBridge.invokeOriginalMethod( + scriptLoader!!.method( + "loadScriptFromFile", + String::class.java, + String::class.java, + Boolean::class.javaPrimitiveType + ), param.thisObject, arrayOf(absolutePath, absolutePath, param.args[2]) + ) + + delete() + } + } + } + + val hook = MethodHookBuilder().run { + before { + setGlobalVariable(param, GLOBAL_NAME, getPayloadString()) + } + + build() + } + + listOf(catalystInstance, scriptLoader).forEach { if (it != null) hook(it, hook) } + } + + private fun hook(instance: Class<*>, hook: XC_MethodHook) { + instance.method( + "loadScriptFromAssets", AssetManager::class.java, String::class.java, Boolean::class.javaPrimitiveType, + ).hook(hook) + + instance.method( + "loadScriptFromFile", String::class.java, String::class.java, Boolean::class.javaPrimitiveType + ).hook(hook) + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/PluginsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PluginsModule.kt similarity index 95% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/PluginsModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PluginsModule.kt index 8aae94b..8da4acd 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/PluginsModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/PluginsModule.kt @@ -1,10 +1,10 @@ -package GoonXposed.xposed.modules +package ShiggyXposed.xposed.modules import android.content.Context import android.util.AtomicFile -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Log -import GoonXposed.xposed.modules.bridge.BridgeModule +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log +import ShiggyXposed.xposed.modules.bridge.BridgeModule import java.io.* /** diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt new file mode 100644 index 0000000..caf9157 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt @@ -0,0 +1,155 @@ +package ShiggyXposed.xposed.modules + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.util.AtomicFile +import android.widget.Toast +import androidx.core.util.writeBytes +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.Utils.Companion.reloadApp +import ShiggyXposed.xposed.Utils.Log +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.* +import kotlinx.serialization.Serializable +import java.io.File +import java.lang.ref.WeakReference + +@Serializable +data class CustomLoadUrl( + val enabled: Boolean = false, val url: String = "" +) + +@Serializable +data class LoaderConfig( + val customLoadUrl: CustomLoadUrl = CustomLoadUrl() +) + +/** + * Module that updates the JS bundle by downloading it from a remote URL. + * + * Shows dialogs when failed allowing retry. + */ +object UpdaterModule : Module() { + private lateinit var config: LoaderConfig + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var lastActivity: WeakReference? = null + + private lateinit var cacheDir: File + private lateinit var bundle: File + private lateinit var etag: File + + private const val TIMEOUT_CACHED = 5000L + private const val TIMEOUT = 10000L + private const val ETAG_FILE = "etag.txt" + private const val CONFIG_FILE = "loader.json" + + private const val DEFAULT_BUNDLE_URL = + "https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/shiggycord.js" + + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() } + val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() } + + bundle = File(cacheDir, Constants.MAIN_SCRIPT_FILE) + etag = File(cacheDir, ETAG_FILE) + + val configFile = File(filesDir, CONFIG_FILE) + + config = runCatching { + if (configFile.exists()) { + JSON.decodeFromString(configFile.readText()) + } else LoaderConfig() + }.getOrDefault(LoaderConfig()) + } + + fun downloadScript(activity: Activity? = null): Job = scope.launch { + try { + HttpClient(CIO) { + expectSuccess = false + install(UserAgent) { agent = Constants.USER_AGENT } + install(HttpRedirect) {} + }.use { client -> + val url = config.customLoadUrl.takeIf { it.enabled }?.url ?: DEFAULT_BUNDLE_URL + Log.i("Fetching JS bundle from: $url") + + val response: HttpResponse = client.get(url) { + headers { + if (etag.exists() && bundle.exists()) { + append(HttpHeaders.IfNoneMatch, etag.readText()) + } + } + + // Retries don't need timeout + if (activity == null) { + timeout { + requestTimeoutMillis = if (!bundle.exists()) TIMEOUT else TIMEOUT_CACHED + } + } + } + + when (response.status) { + HttpStatusCode.OK -> { + val bytes: ByteArray = response.body() + AtomicFile(bundle).writeBytes(bytes) + + val newTag = response.headers[HttpHeaders.ETag] + if (!newTag.isNullOrEmpty()) etag.writeText(newTag) else etag.delete() + + Log.i("Bundle updated (${bytes.size} bytes)") + + // This is a retry, so we show a dialog + if (activity != null) { + withContext(Dispatchers.Main) { + AlertDialog.Builder(activity).setTitle("ShiggyCord Update Successful") + .setMessage("A reload is required for changes to take effect.") + .setPositiveButton("Reload") { dialog, _ -> + reloadApp() + dialog.dismiss() + }.setCancelable(false).show() + } + } + } + + HttpStatusCode.NotModified -> { + Log.i("Server responded with 304, no changes") + } + + else -> { + throw ResponseException(response, "Received status: ${response.status}") + } + } + } + } catch (e: Throwable) { + Log.e("Failed to download script", e) + showErrorDialog(e) + } + } + + override fun onActivity(activity: Activity) { + lastActivity = WeakReference(activity) + } + + fun showErrorDialog(e: Throwable) { + val activity = lastActivity?.get() ?: return + + activity.runOnUiThread { + } + } + + fun resetLoaderConfig(context: Context) { + val filesDir = File(context.dataDir, Constants.FILES_DIR) + val config = File(filesDir, CONFIG_FILE) + if (config.exists()) config.delete() + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt similarity index 97% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt index 2a121e7..ec6111e 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/FontsModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt @@ -1,4 +1,4 @@ -package GoonXposed.xposed.modules.appearance +package ShiggyXposed.xposed.modules.appearance import android.content.res.AssetManager import android.graphics.Typeface @@ -9,10 +9,10 @@ import android.os.Build import de.robv.android.xposed.XC_MethodReplacement import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Companion.JSON -import GoonXposed.xposed.Utils.Log +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.Utils.Log import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.cio.* diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/SysColorsModule.kt similarity index 95% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/SysColorsModule.kt index 3807b09..31833e1 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/SysColorsModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/SysColorsModule.kt @@ -1,11 +1,11 @@ -package GoonXposed.xposed.modules.appearance +package ShiggyXposed.xposed.modules.appearance import android.R.color import android.app.AndroidAppHelper import android.content.Context import android.os.Build import androidx.core.content.ContextCompat -import GoonXposed.xposed.Module +import ShiggyXposed.xposed.Module import kotlinx.serialization.json.* import java.lang.ref.WeakReference diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/ThemesModule.kt similarity index 96% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/ThemesModule.kt index 2e08714..4c55133 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/appearance/ThemesModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/ThemesModule.kt @@ -1,12 +1,12 @@ -package GoonXposed.xposed.modules.appearance +package ShiggyXposed.xposed.modules.appearance import android.content.Context import android.content.res.Resources import androidx.core.graphics.toColorInt import de.robv.android.xposed.callbacks.XC_LoadPackage -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Companion.JSON +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Companion.JSON import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObjectBuilder diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt similarity index 96% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt index 5216ee0..6d21887 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/AdditionalBridgeMethodsModule.kt @@ -1,4 +1,4 @@ -package GoonXposed.xposed.modules.bridge +package ShiggyXposed.xposed.modules.bridge import android.app.Activity import android.app.AlertDialog @@ -7,8 +7,8 @@ import android.content.ClipboardManager import android.content.Context import android.os.Build import android.widget.Toast -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils import java.io.File object AdditionalBridgeMethodsModule : Module() { diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/BridgeModule.kt similarity index 96% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/BridgeModule.kt index 17d6474..d5808aa 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/bridge/BridgeModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/bridge/BridgeModule.kt @@ -1,10 +1,10 @@ -package GoonXposed.xposed.modules.bridge +package ShiggyXposed.xposed.modules.bridge import de.robv.android.xposed.callbacks.XC_LoadPackage -import GoonXposed.xposed.BuildConfig -import GoonXposed.xposed.Constants -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Log +import ShiggyXposed.xposed.BuildConfig +import ShiggyXposed.xposed.Constants +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log import java.lang.reflect.Method /** 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 new file mode 100644 index 0000000..2e084c3 --- /dev/null +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockCrashReportingModule.kt @@ -0,0 +1,50 @@ +package ShiggyXposed.xposed.modules.no_track + +import android.content.Context +import de.robv.android.xposed.callbacks.XC_LoadPackage +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log + +/** + * Hooks Discord's crash reporting to disable Sentry initialization. + * While Discord doesn't have Sentry auto-initialization on by default, we still hook the content provider just to be safe. + */ +object BlockCrashReportingModule : Module() { + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { + val crashReportingClass = classLoader.safeLoadClass("com.discord.crash_reporting.CrashReporting") + crashReportingClass?.apply { + + // This only exists on 30720x and above + runCatching { + hookMethod("isDisabled") { + before { + Log.i("Forced CrashReporting.isDisabled() to true") + 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 + } + } + } + } + + val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider") + sentryInitProviderClass?.hookMethod("onCreate") { + before { + Log.i("Blocked SentryInitProvider initialization") + result = true + } + } + + return@with + } +} diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt similarity index 85% rename from app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt rename to app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt index e47548d..454028f 100644 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/no_track/BlockDeepLinksTrackingModule.kt @@ -1,9 +1,9 @@ -package GoonXposed.xposed.modules.no_track +package ShiggyXposed.xposed.modules.no_track import android.content.Context import de.robv.android.xposed.callbacks.XC_LoadPackage -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Log +import ShiggyXposed.xposed.Module +import ShiggyXposed.xposed.Utils.Log /** * Hooks Discord's deep links tracking to disable AppsFlyer initialization. diff --git a/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockCrashReportingModule.kt b/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockCrashReportingModule.kt deleted file mode 100644 index 1c8186d..0000000 --- a/app/src/main/kotlin/io/github/revenge/xposed/modules/no_track/BlockCrashReportingModule.kt +++ /dev/null @@ -1,46 +0,0 @@ -package GoonXposed.xposed.modules.no_track - -import android.content.Context -import de.robv.android.xposed.callbacks.XC_LoadPackage -import GoonXposed.xposed.Module -import GoonXposed.xposed.Utils.Log - -/** - * Hooks Discord's crash reporting to disable Sentry initialization. - * While Discord doesn't have Sentry auto-initialization on by default, we still hook the content provider just to be safe. - */ -object BlockCrashReportingModule : Module() { - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { - val crashReportingClass = classLoader.safeLoadClass("com.discord.crash_reporting.CrashReporting") - crashReportingClass?.apply { - // Hooking this will result in a crash after a few seconds, since Discord expects initialization to complete when setting a Sentry tag. - // So we also hook isDisabled to make sure those calls are no-ops. - hookMethod( - "init", Context::class.java, String::class.java - ) { - before { - Log.i("Blocked CrashReporting initialization") - result = null - } - } - runCatching { - hookMethod("isDisabled") { - before { - Log.i("Forced CrashReporting.isDisabled() to true") - result = true - } - } - } - - val sentryInitProviderClass = classLoader.safeLoadClass("io.sentry.android.core.SentryInitProvider") - sentryInitProviderClass?.hookMethod("onCreate") { - before { - Log.i("Blocked SentryInitProvider initialization") - result = true - } - } - - return@with - } - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 09117af..bb6ec98 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,2 @@ -rootProject.name = "KettuXposed" +rootProject.name = "ShiggyXposed" include(":app")