This commit is contained in:
@@ -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