From c5d43f5b51126ca0008653aada6d5c67bd793bd7 Mon Sep 17 00:00:00 2001 From: StanPonomarev Date: Fri, 21 Aug 2026 16:24:04 +0200 Subject: [PATCH] feat: disable outdated version screen --- patches-list.json | 31 +++- patches/build.gradle.kts | 50 +++++- .../patches/MorphePatchIntegrationTest.kt | 152 ++++++++++++++++++ .../DisableOutdatedVersionScreenPatch.kt | 48 ++++++ .../kotlin/app/twitter/patches/NoopPatch.kt | 13 -- 5 files changed, 274 insertions(+), 20 deletions(-) create mode 100644 patches/src/integrationTest/kotlin/app/twitter/patches/MorphePatchIntegrationTest.kt create mode 100644 patches/src/main/kotlin/app/twitter/patches/DisableOutdatedVersionScreenPatch.kt delete mode 100644 patches/src/main/kotlin/app/twitter/patches/NoopPatch.kt diff --git a/patches-list.json b/patches-list.json index 4c932cb..12f1e47 100644 --- a/patches-list.json +++ b/patches-list.json @@ -1,5 +1,32 @@ { "NOTE": "Do NOT manually edit this file. This file is automatically updated when semantic release (release.yml) runs. Manually editing this file can break your releases and break third party tools that use this file.", - "version": "0.0.0", - "patches": [] + "version": "1.0.0", + "patches": [ + { + "name": "Disable outdated version screen", + "description": "Prevents X from blocking app access when it marks the client version as outdated.", + "default": true, + "dependencies": [], + "compatiblePackages": [ + { + "packageName": "com.twitter.android", + "name": "X", + "description": null, + "apkFileType": "APKM", + "appIconColor": "#000000", + "signatures": null, + "targets": [ + { + "version": "12.7.1-release.0", + "versionCodes": null, + "isExperimental": false, + "minSdk": 28, + "description": null + } + ] + } + ], + "options": [] + } + ] } \ No newline at end of file diff --git a/patches/build.gradle.kts b/patches/build.gradle.kts index 5d5db7d..dea653a 100644 --- a/patches/build.gradle.kts +++ b/patches/build.gradle.kts @@ -3,11 +3,11 @@ group = "app.twitter" patches { about { name = "X Patches" - description = "Local patches for X (Twitter)" - source = "local" - author = "local" - contact = "N/A" - website = "N/A" + description = "Patches for X (Twitter)" + source = "https://git.krtss.de/StanPonomarev/twitter-patch" + author = "Stan Ponomarev" + contact = "contact@krtss.de" + website = "https://git.krtss.de/StanPonomarev/twitter-patch" license = "GPLv3" } } @@ -22,9 +22,21 @@ kotlin { // generatePatchesList task but never bundled into the APK. val patchListGeneratorClasspath = configurations.create("patchListGeneratorClasspath") +val integrationTestSourceSet = sourceSets.create("integrationTest") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output +} + +configurations[integrationTestSourceSet.implementationConfigurationName] + .extendsFrom(configurations.testImplementation.get()) +configurations[integrationTestSourceSet.runtimeOnlyConfigurationName] + .extendsFrom(configurations.testRuntimeOnly.get()) + dependencies { compileOnly(libs.gson) patchListGeneratorClasspath(libs.gson) + add(integrationTestSourceSet.implementationConfigurationName, kotlin("test-junit")) + add(integrationTestSourceSet.implementationConfigurationName, libs.gson) } tasks { @@ -42,3 +54,31 @@ tasks { dependsOn("generatePatchesList") } } + +tasks.register("integrationTest") { + group = "verification" + description = "Downloads Morphe Desktop and applies the patch bundle to twitter.apkm." + + dependsOn("buildAndroid") + + testClassesDirs = integrationTestSourceSet.output.classesDirs + classpath = integrationTestSourceSet.runtimeClasspath + + val inputApkm = rootProject.layout.projectDirectory.file("twitter.apkm") + val patchBundle = layout.buildDirectory.file("libs/${project.name}-${project.version}.mpp") + val morpheCache = layout.buildDirectory.dir("verification/tools") + + inputs.file(inputApkm) + inputs.file(patchBundle) + + systemProperty("integrationTest.inputApkm", inputApkm.asFile.absolutePath) + systemProperty("integrationTest.patchBundle", patchBundle.get().asFile.absolutePath) + systemProperty("integrationTest.morpheCache", morpheCache.get().asFile.absolutePath) + systemProperty( + "integrationTest.javaExecutable", + javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + }.get().executablePath.asFile.absolutePath + ) +} + diff --git a/patches/src/integrationTest/kotlin/app/twitter/patches/MorphePatchIntegrationTest.kt b/patches/src/integrationTest/kotlin/app/twitter/patches/MorphePatchIntegrationTest.kt new file mode 100644 index 0000000..db77abd --- /dev/null +++ b/patches/src/integrationTest/kotlin/app/twitter/patches/MorphePatchIntegrationTest.kt @@ -0,0 +1,152 @@ +package app.twitter.patches + +import com.google.gson.JsonParser +import java.net.HttpURLConnection +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption.REPLACE_EXISTING +import java.security.MessageDigest +import java.util.HexFormat +import java.util.concurrent.TimeUnit +import java.util.zip.ZipFile +import kotlin.io.path.absolutePathString +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.fileSize +import kotlin.io.path.inputStream +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MorphePatchIntegrationTest { + @Test + fun `applies outdated version patch to target APKM`() { + val inputApkm = requiredPathProperty("integrationTest.inputApkm") + val patchBundle = requiredPathProperty("integrationTest.patchBundle") + val morpheCache = requiredPathProperty("integrationTest.morpheCache") + val javaExecutable = requiredPathProperty("integrationTest.javaExecutable") + + assertTrue(inputApkm.exists(), "Missing integration-test input: $inputApkm") + assertTrue(patchBundle.exists(), "Missing patch bundle: $patchBundle") + + val morpheJar = downloadMorphe(morpheCache) + val workDirectory = Files.createTempDirectory("twitter-patch-integration-") + + try { + val patchedApk = workDirectory.resolve("twitter-patched.apk") + val resultFile = workDirectory.resolve("patching-result.json") + val processLog = workDirectory.resolve("morphe.log") + + val process = ProcessBuilder( + javaExecutable.absolutePathString(), + "-jar", + morpheJar.absolutePathString(), + "patch", + "--patches=${patchBundle.absolutePathString()}", + "--exclusive", + "--enable=$PATCH_NAME", + "--unsigned", + "--out=${patchedApk.absolutePathString()}", + "--result-file=${resultFile.absolutePathString()}", + inputApkm.absolutePathString() + ) + .directory(workDirectory.toFile()) + .redirectErrorStream(true) + .redirectOutput(processLog.toFile()) + .start() + + val completed = process.waitFor(10, TimeUnit.MINUTES) + if (!completed) process.destroyForcibly().waitFor() + val log = Files.readString(processLog) + + assertTrue(completed, "Morphe timed out after 10 minutes:\n$log") + assertEquals(0, process.exitValue(), "Morphe failed:\n$log") + assertTrue(patchedApk.exists() && patchedApk.fileSize() > 0, "Patched APK was not created") + assertTrue(resultFile.exists() && resultFile.fileSize() > 0, "Patching report was not created") + + ZipFile(patchedApk.toFile()).use { apk -> + assertTrue(apk.getEntry("AndroidManifest.xml") != null, "Morphe output is not an APK") + } + + val result = JsonParser.parseString(Files.readString(resultFile)).asJsonObject + if (result.has("success")) { + assertTrue(result.get("success").asBoolean, "Morphe reported an unsuccessful run") + } + assertEquals("com.twitter.android", result.get("packageName").asString) + assertEquals("12.7.1-release.0", result.get("packageVersion").asString) + + val steps = result.getAsJsonArray("patchingSteps").map { it.asJsonObject } + assertTrue(steps.all { it.get("success").asBoolean }, "A Morphe patching step failed") + assertTrue( + steps.map { it.get("step").asString }.containsAll(listOf("PATCHING", "REBUILDING")), + "Morphe did not complete patching and rebuilding" + ) + assertFalse( + steps.any { it.get("step").asString == "SIGNING" }, + "Unsigned integration test unexpectedly signed the APK" + ) + + val appliedPatchNames = result.getAsJsonArray("appliedPatches") + .map { it.asJsonObject.get("name").asString } + assertTrue(PATCH_NAME in appliedPatchNames, "Expected patch was not applied: $appliedPatchNames") + assertTrue(result.getAsJsonArray("failedPatches").isEmpty, "Morphe reported failed patches") + } finally { + workDirectory.toFile().deleteRecursively() + } + } + + private fun downloadMorphe(cacheDirectory: Path): Path { + cacheDirectory.createDirectories() + val target = cacheDirectory.resolve("morphe-desktop-$MORPHE_VERSION-all.jar") + if (target.exists() && sha256(target) == MORPHE_SHA256) return target + + val partial = Files.createTempFile(cacheDirectory, "morphe-desktop-", ".part") + try { + val connection = URI(MORPHE_URL).toURL().openConnection() as HttpURLConnection + connection.instanceFollowRedirects = true + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + connection.setRequestProperty("User-Agent", "twitter-patch-integration-test") + + connection.inputStream.use { input -> + Files.copy(input, partial, REPLACE_EXISTING) + } + assertEquals(MORPHE_SHA256, sha256(partial), "Downloaded Morphe checksum mismatch") + Files.move(partial, target, REPLACE_EXISTING) + return target + } finally { + partial.deleteIfExists() + } + } + + private fun sha256(path: Path): String { + val digest = MessageDigest.getInstance("SHA-256") + path.inputStream().buffered().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return HexFormat.of().formatHex(digest.digest()) + } + + private fun requiredPathProperty(name: String): Path { + val value = System.getProperty(name) + assertFalse(value.isNullOrBlank(), "Missing system property $name") + return Path.of(value) + } + + private companion object { + const val PATCH_NAME = "Disable outdated version screen" + const val MORPHE_VERSION = "1.13.1" + const val MORPHE_SHA256 = "a212531f68d3ada4b504bf9dac3e02460589f3bf8868ee66e1b4780152d3466b" + const val MORPHE_URL = + "https://github.com/MorpheApp/morphe-desktop/releases/download/" + + "v$MORPHE_VERSION/morphe-desktop-$MORPHE_VERSION-all.jar" + } +} diff --git a/patches/src/main/kotlin/app/twitter/patches/DisableOutdatedVersionScreenPatch.kt b/patches/src/main/kotlin/app/twitter/patches/DisableOutdatedVersionScreenPatch.kt new file mode 100644 index 0000000..c13ad40 --- /dev/null +++ b/patches/src/main/kotlin/app/twitter/patches/DisableOutdatedVersionScreenPatch.kt @@ -0,0 +1,48 @@ +package app.twitter.patches + +import app.morphe.patcher.extensions.InstructionExtensions.addInstructions +import app.morphe.patcher.Fingerprint +import app.morphe.patcher.patch.bytecodePatch +import app.twitter.patches.shared.Constants.COMPATIBILITY_X + +private val outdatedVersionEnforcementFingerprint = Fingerprint( + definingClass = "Lcom/twitter/subsystem/clientshutdown/o;", + name = "invoke", + returnType = "Ljava/lang/Object;", + parameters = listOf("Ljava/lang/Object;"), + strings = listOf("Target version is ", "Detected shutdown") +) + +private val shutdownStateFingerprint = Fingerprint( + definingClass = "Lcom/twitter/subsystem/clientshutdown/q;", + name = "isShutdown", + returnType = "Z", + parameters = emptyList() +) + +@Suppress("unused") +val disableOutdatedVersionScreenPatch = bytecodePatch( + name = "Disable outdated version screen", + description = "Prevents X from blocking app access when it marks the client version as outdated.", + default = true +) { + compatibleWith(COMPATIBILITY_X) + + execute { + outdatedVersionEnforcementFingerprint.method.addInstructions( + 0, + """ + sget-object v0, Lkotlin/Unit;->a:Lkotlin/Unit; + return-object v0 + """ + ) + + shutdownStateFingerprint.method.addInstructions( + 0, + """ + const/4 v0, 0x0 + return v0 + """ + ) + } +} diff --git a/patches/src/main/kotlin/app/twitter/patches/NoopPatch.kt b/patches/src/main/kotlin/app/twitter/patches/NoopPatch.kt deleted file mode 100644 index 7312e90..0000000 --- a/patches/src/main/kotlin/app/twitter/patches/NoopPatch.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.twitter.patches - -import app.morphe.patcher.patch.bytecodePatch -import app.twitter.patches.shared.Constants.COMPATIBILITY_X - -@Suppress("unused") -val noopPatch = bytecodePatch( - name = "No-op", - description = "Makes no changes; validates the X patching pipeline.", - default = true -) { - compatibleWith(COMPATIBILITY_X) -}