Modify extension bytecode to fix SimpleDateFormat cannot parse errors
This commit is contained in:
@@ -22,6 +22,7 @@ import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import org.w3c.dom.Element
|
||||
import org.w3c.dom.Node
|
||||
import suwayomi.tachidesk.manga.impl.util.BytecodeEditor
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
|
||||
import xyz.nulldev.androidcompat.pm.toPackageInfo
|
||||
@@ -80,6 +81,8 @@ object PackageTools {
|
||||
""".trimIndent()
|
||||
)
|
||||
handler.dump(errorFile, emptyArray<String>())
|
||||
} else {
|
||||
BytecodeEditor.fixAndroidClasses(jarFilePath.toFile())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package suwayomi.tachidesk.manga.impl.util
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.objectweb.asm.ClassReader
|
||||
import org.objectweb.asm.ClassVisitor
|
||||
import org.objectweb.asm.ClassWriter
|
||||
import org.objectweb.asm.FieldVisitor
|
||||
import org.objectweb.asm.Handle
|
||||
import org.objectweb.asm.MethodVisitor
|
||||
import org.objectweb.asm.Opcodes
|
||||
import org.objectweb.asm.tree.ClassNode
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.use
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarFile
|
||||
import java.util.jar.JarOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
object BytecodeEditor {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
fun fixAndroidClasses(jarFile: File) {
|
||||
val nodes = loadClasses(jarFile)
|
||||
.mapValues { (className, classFileBuffer) ->
|
||||
logger.trace { "Processing class $className" }
|
||||
transform(classFileBuffer)
|
||||
} + loadNonClasses(jarFile)
|
||||
|
||||
saveAsJar(nodes, jarFile)
|
||||
}
|
||||
|
||||
private fun loadClasses(jar: File): Map<String, ByteArray> {
|
||||
return JarFile(jar).use { jarFile ->
|
||||
jarFile.entries()
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
readJar(jarFile, it)
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readJar(jar: JarFile, entry: JarEntry): Pair<String, ByteArray>? {
|
||||
return try {
|
||||
jar.getInputStream(entry).use { stream ->
|
||||
if (entry.name.endsWith(".class")) {
|
||||
val bytes = stream.readBytes()
|
||||
if (bytes.size < 4) {
|
||||
// Invalid class size
|
||||
return@use null
|
||||
}
|
||||
val cafebabe = String.format(
|
||||
"%02X%02X%02X%02X",
|
||||
bytes[0],
|
||||
bytes[1],
|
||||
bytes[2],
|
||||
bytes[3]
|
||||
)
|
||||
if (cafebabe.toLowerCase() != "cafebabe") {
|
||||
// Corrupted class
|
||||
return@use null
|
||||
}
|
||||
|
||||
getNode(bytes).name to bytes
|
||||
} else null
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
logger.error(e) { "Error loading jar file" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNode(bytes: ByteArray): ClassNode {
|
||||
val cr = ClassReader(bytes)
|
||||
return ClassNode().also { cr.accept(it, ClassReader.EXPAND_FRAMES) }
|
||||
}
|
||||
|
||||
private const val simpleDateFormat = "java/text/SimpleDateFormat"
|
||||
private const val replacementSimpleDateFormat = "xyz/nulldev/androidcompat/replace/SimpleDateFormat"
|
||||
|
||||
private fun String?.replaceFormatFully() = if (this == simpleDateFormat) {
|
||||
replacementSimpleDateFormat
|
||||
} else this
|
||||
private fun String?.replaceFormat() = this?.replace(simpleDateFormat, replacementSimpleDateFormat)
|
||||
|
||||
private fun transform(classfileBuffer: ByteArray): ByteArray {
|
||||
val cr = ClassReader(classfileBuffer)
|
||||
val cw = ClassWriter(cr, 0)
|
||||
cr.accept(
|
||||
object : ClassVisitor(Opcodes.ASM5, cw) {
|
||||
override fun visitField(
|
||||
access: Int,
|
||||
name: String?,
|
||||
desc: String?,
|
||||
signature: String?,
|
||||
cst: Any?
|
||||
): FieldVisitor? {
|
||||
logger.trace { "CLass Field" to "${desc.replaceFormat()}: ${cst?.let { it::class.java.simpleName }}: $cst" }
|
||||
return super.visitField(access, name, desc.replaceFormat(), signature, cst)
|
||||
}
|
||||
|
||||
override fun visit(
|
||||
version: Int,
|
||||
access: Int,
|
||||
name: String?,
|
||||
signature: String?,
|
||||
superName: String?,
|
||||
interfaces: Array<out String>?
|
||||
) {
|
||||
logger.trace { "Visiting $name: $signature: $superName" }
|
||||
super.visit(version, access, name, signature, superName, interfaces)
|
||||
}
|
||||
|
||||
override fun visitMethod(
|
||||
access: Int,
|
||||
name: String,
|
||||
desc: String,
|
||||
signature: String?,
|
||||
exceptions: Array<String?>?
|
||||
): MethodVisitor {
|
||||
logger.trace { "Processing method $name: ${desc.replaceFormat()}: $signature" }
|
||||
val mv: MethodVisitor? = super.visitMethod(
|
||||
access, name, desc.replaceFormat(), signature, exceptions
|
||||
)
|
||||
return object : MethodVisitor(Opcodes.ASM5, mv) {
|
||||
override fun visitLdcInsn(cst: Any?) {
|
||||
logger.trace { "Ldc" to "${cst?.let { "${it::class.java.simpleName}: $it" }}" }
|
||||
super.visitLdcInsn(cst)
|
||||
}
|
||||
|
||||
override fun visitTypeInsn(opcode: Int, type: String?) {
|
||||
logger.trace {
|
||||
"Type" to "$opcode: ${type.replaceFormatFully()}"
|
||||
}
|
||||
super.visitTypeInsn(
|
||||
opcode,
|
||||
type.replaceFormatFully()
|
||||
)
|
||||
}
|
||||
|
||||
override fun visitMethodInsn(
|
||||
opcode: Int,
|
||||
owner: String?,
|
||||
name: String?,
|
||||
desc: String?,
|
||||
itf: Boolean
|
||||
) {
|
||||
logger.trace {
|
||||
"Method" to "$opcode: ${owner.replaceFormatFully()}: $name: ${desc.replaceFormat()}"
|
||||
}
|
||||
super.visitMethodInsn(
|
||||
opcode,
|
||||
owner.replaceFormatFully(),
|
||||
name,
|
||||
desc.replaceFormat(),
|
||||
itf
|
||||
)
|
||||
}
|
||||
|
||||
override fun visitFieldInsn(
|
||||
opcode: Int,
|
||||
owner: String?,
|
||||
name: String?,
|
||||
desc: String?
|
||||
) {
|
||||
logger.trace { "Field" to "$opcode: $owner: $name: ${desc.replaceFormat()}" }
|
||||
super.visitFieldInsn(opcode, owner, name, desc.replaceFormat())
|
||||
}
|
||||
|
||||
override fun visitInvokeDynamicInsn(
|
||||
name: String?,
|
||||
desc: String?,
|
||||
bsm: Handle?,
|
||||
vararg bsmArgs: Any?
|
||||
) {
|
||||
logger.trace { "InvokeDynamic" to "$name: $desc" }
|
||||
super.visitInvokeDynamicInsn(name, desc, bsm, *bsmArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
0
|
||||
)
|
||||
return cw.toByteArray()
|
||||
}
|
||||
|
||||
private fun loadNonClasses(jarFile: File): Map<String, ByteArray> {
|
||||
val entries = mutableMapOf<String, ByteArray>()
|
||||
ZipInputStream(jarFile.inputStream()).use { stream ->
|
||||
var nextEntry: ZipEntry?
|
||||
while (stream.nextEntry.also { nextEntry = it } != null) {
|
||||
nextEntry?.use(stream) { entry ->
|
||||
if (!entry.name.endsWith(".class") && !entry.isDirectory) {
|
||||
val bytes = stream.readBytes()
|
||||
entries[entry.name] = bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun saveAsJar(outBytes: Map<String, ByteArray>, file: File) {
|
||||
JarOutputStream(file.outputStream()).use { out ->
|
||||
outBytes.forEach { (entry, value) ->
|
||||
// Append extension to class entries
|
||||
out.putNextEntry(
|
||||
ZipEntry(
|
||||
entry + if (entry.contains(".")) "" else ".class"
|
||||
)
|
||||
)
|
||||
out.write(value)
|
||||
out.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,8 @@ object PackageTools {
|
||||
""".trimIndent()
|
||||
)
|
||||
handler.dump(errorFile, emptyArray<String>())
|
||||
} else {
|
||||
BytecodeEditor.fixAndroidClasses(jarFilePath.toFile())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.storage
|
||||
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
fun ZipEntry.use(stream: ZipInputStream, block: (ZipEntry) -> Unit) {
|
||||
var exception: Throwable? = null
|
||||
try {
|
||||
return block(this)
|
||||
} catch (e: Throwable) {
|
||||
exception = e
|
||||
throw e
|
||||
} finally {
|
||||
if (exception == null) {
|
||||
stream.closeEntry()
|
||||
} else {
|
||||
try {
|
||||
stream.closeEntry()
|
||||
} catch (closeException: Throwable) {
|
||||
exception.addSuppressed(closeException)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
||||
import suwayomi.tachidesk.server.applicationSetup
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TestExtensions {
|
||||
@@ -48,7 +49,7 @@ class TestExtensions {
|
||||
@BeforeAll
|
||||
fun setup() {
|
||||
val dataRoot = File("tmp/TestDesk").absolutePath
|
||||
System.setProperty("suwayomi.tachidesk.rootDir", dataRoot)
|
||||
System.setProperty("suwayomi.tachidesk.server.rootDir", dataRoot)
|
||||
applicationSetup()
|
||||
setLoggingEnabled(false)
|
||||
|
||||
@@ -63,6 +64,7 @@ class TestExtensions {
|
||||
updateExtension(it.pkgName)
|
||||
}
|
||||
else -> {
|
||||
uninstallExtension(it.pkgName)
|
||||
installExtension(it.pkgName)
|
||||
}
|
||||
}
|
||||
@@ -77,10 +79,11 @@ class TestExtensions {
|
||||
fun runTest() {
|
||||
runBlocking(Dispatchers.Default) {
|
||||
val semaphore = Semaphore(10)
|
||||
sources.mapIndexed { index, source ->
|
||||
val popularCount = AtomicInteger(1)
|
||||
sources.map { source ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
logger.info { "$index - Now fetching popular manga from $source" }
|
||||
logger.info { "${popularCount.getAndIncrement()} - Now fetching popular manga from $source" }
|
||||
try {
|
||||
mangaToFetch += source to (
|
||||
source.fetchPopularManga(1)
|
||||
@@ -102,10 +105,11 @@ class TestExtensions {
|
||||
)
|
||||
logger.info { "Now fetching manga info from ${mangaToFetch.size} sources" }
|
||||
|
||||
mangaToFetch.mapIndexed { index, (source, manga) ->
|
||||
val mangaCount = AtomicInteger(1)
|
||||
mangaToFetch.map { (source, manga) ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
logger.info { "$index - Now fetching manga from $source" }
|
||||
logger.info { "${mangaCount.getAndIncrement()} - Now fetching manga from $source" }
|
||||
try {
|
||||
manga.copyFrom(source.fetchMangaDetails(manga).awaitSingleRepeat())
|
||||
manga.initialized = true
|
||||
@@ -127,10 +131,11 @@ class TestExtensions {
|
||||
)
|
||||
logger.info { "Now fetching manga chapters from ${mangaToFetch.size} sources" }
|
||||
|
||||
mangaToFetch.filter { it.second.initialized }.mapIndexed { index, (source, manga) ->
|
||||
val chapterCount = AtomicInteger(1)
|
||||
mangaToFetch.filter { it.second.initialized }.map { (source, manga) ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
logger.info { "$index - Now fetching manga chapters from $source" }
|
||||
logger.info { "${chapterCount.getAndIncrement()} - Now fetching manga chapters from $source" }
|
||||
try {
|
||||
chaptersToFetch += Triple(
|
||||
source,
|
||||
@@ -160,10 +165,11 @@ class TestExtensions {
|
||||
}
|
||||
)
|
||||
|
||||
chaptersToFetch.mapIndexed { index, (source, manga, chapter) ->
|
||||
val pageListCount = AtomicInteger(1)
|
||||
chaptersToFetch.map { (source, manga, chapter) ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
logger.info { "$index - Now fetching page list from $source" }
|
||||
logger.info { "${pageListCount.getAndIncrement()} - Now fetching page list from $source" }
|
||||
try {
|
||||
source.fetchPageList(chapter).awaitSingleRepeat()
|
||||
} catch (e: Exception) {
|
||||
|
||||
Reference in New Issue
Block a user