This commit is contained in:
+29
-2
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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