This commit is contained in:
@@ -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<Test>("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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user