diff --git a/AndroidCompat/build.gradle.kts b/AndroidCompat/build.gradle.kts index d56ed384..d9bfa3d2 100644 --- a/AndroidCompat/build.gradle.kts +++ b/AndroidCompat/build.gradle.kts @@ -36,12 +36,6 @@ dependencies { // Config API implementation(project(":AndroidCompat:Config")) - // dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon - compileOnly("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon") - - // APK parser - compileOnly("net.dongliu:apk-parser:2.6.10") - // APK sig verifier compileOnly("com.android.tools.build:apksig:4.2.0-alpha13") diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/pm/InstalledPackage.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/pm/InstalledPackage.kt index ce94d6d9..bcabfe3c 100644 --- a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/pm/InstalledPackage.kt +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/pm/InstalledPackage.kt @@ -22,7 +22,7 @@ data class InstalledPackage(val root: File) { val icon = File(root, "icon.png") val info: PackageInfo - get() = ApkParsers.getMetaInfo(apk).toPackageInfo(root, apk).also { + get() = ApkParsers.getMetaInfo(apk).toPackageInfo(apk).also { val parsed = ApkFile(apk) val dbFactory = DocumentBuilderFactory.newInstance() val dBuilder = dbFactory.newDocumentBuilder() @@ -82,12 +82,14 @@ data class InstalledPackage(val root: File) { } } - private fun NodeList.toList(): List { - val out = mutableListOf() + companion object { + fun NodeList.toList(): List { + val out = mutableListOf() - for(i in 0 until length) - out += item(i) + for (i in 0 until length) + out += item(i) - return out + return out + } } } \ No newline at end of file diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/pm/PackageUtil.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/pm/PackageUtil.kt index b42633be..2294cb65 100644 --- a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/pm/PackageUtil.kt +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/pm/PackageUtil.kt @@ -6,7 +6,7 @@ import android.content.pm.PackageInfo import net.dongliu.apk.parser.bean.ApkMeta import java.io.File -fun ApkMeta.toPackageInfo(root: File, apk: File): PackageInfo { +fun ApkMeta.toPackageInfo(apk: File): PackageInfo { return PackageInfo().also { it.packageName = packageName it.versionCode = versionCode.toInt() diff --git a/build.gradle.kts b/build.gradle.kts index 80132a3c..a8dcfe6f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -77,5 +77,11 @@ configure(projects) { // to get application content root implementation("net.harawata:appdirs:1.2.0") + + // dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon + implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon") + + // APK parser + implementation("net.dongliu:apk-parser:2.6.10") } } \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 27cae38a..3048893f 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -58,9 +58,6 @@ dependencies { implementation("org.jsoup:jsoup:1.13.1") implementation("com.github.salomonbrys.kotson:kotson:2.5.0") - // dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon - implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon") - // api implementation("io.javalin:javalin:3.12.0") diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/Hash.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/Hash.kt new file mode 100644 index 00000000..a8906320 --- /dev/null +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/Hash.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.util.lang + +import java.security.MessageDigest + +object Hash { + + private val chars = charArrayOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f' + ) + + private val MD5 get() = MessageDigest.getInstance("MD5") + + private val SHA256 get() = MessageDigest.getInstance("SHA-256") + + fun sha256(bytes: ByteArray): String { + return encodeHex(SHA256.digest(bytes)) + } + + fun sha256(string: String): String { + return sha256(string.toByteArray()) + } + + fun md5(bytes: ByteArray): String { + return encodeHex(MD5.digest(bytes)) + } + + fun md5(string: String): String { + return md5(string.toByteArray()) + } + + private fun encodeHex(data: ByteArray): String { + val l = data.size + val out = CharArray(l shl 1) + var i = 0 + var j = 0 + while (i < l) { + out[j++] = chars[(240 and data[i].toInt()).ushr(4)] + out[j++] = chars[15 and data[i].toInt()] + i++ + } + return String(out) + } +} diff --git a/server/src/main/kotlin/ir/armor/tachidesk/impl/Extension.kt b/server/src/main/kotlin/ir/armor/tachidesk/impl/Extension.kt index bc7d1302..c821a454 100644 --- a/server/src/main/kotlin/ir/armor/tachidesk/impl/Extension.kt +++ b/server/src/main/kotlin/ir/armor/tachidesk/impl/Extension.kt @@ -8,17 +8,24 @@ package ir.armor.tachidesk.impl * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import android.net.Uri -import com.googlecode.d2j.dex.Dex2jar -import com.googlecode.d2j.reader.MultiDexFileReader -import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory -import eu.kanade.tachiyomi.source.online.HttpSource import ir.armor.tachidesk.impl.ExtensionsList.extensionTableAsDataClass -import ir.armor.tachidesk.impl.util.APKExtractor import ir.armor.tachidesk.impl.util.CachedImageResponse.getCachedImageResponse +import ir.armor.tachidesk.impl.util.PackageTools.EXTENSION_FEATURE +import ir.armor.tachidesk.impl.util.PackageTools.LIB_VERSION_MAX +import ir.armor.tachidesk.impl.util.PackageTools.LIB_VERSION_MIN +import ir.armor.tachidesk.impl.util.PackageTools.METADATA_NSFW +import ir.armor.tachidesk.impl.util.PackageTools.METADATA_SOURCE_CLASS +import ir.armor.tachidesk.impl.util.PackageTools.dex2jar +import ir.armor.tachidesk.impl.util.PackageTools.getPackageInfo +import ir.armor.tachidesk.impl.util.PackageTools.getSignatureHash +import ir.armor.tachidesk.impl.util.PackageTools.loadExtensionSources +import ir.armor.tachidesk.impl.util.PackageTools.trustedSignatures import ir.armor.tachidesk.impl.util.await import ir.armor.tachidesk.model.database.ExtensionTable import ir.armor.tachidesk.model.database.SourceTable @@ -27,7 +34,6 @@ import mu.KotlinLogging import okhttp3.Request import okio.buffer import okio.sink -import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.select @@ -36,59 +42,10 @@ import org.jetbrains.exposed.sql.update import uy.kohesive.injekt.injectLazy import java.io.File import java.io.InputStream -import java.net.URL -import java.net.URLClassLoader -import java.nio.file.Files -import java.nio.file.Path object Extension { private val logger = KotlinLogging.logger {} - /** - * Convert dex to jar, a wrapper for the dex2jar library - */ - private fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) { - // adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine - // source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java - - val jarFilePath = File(jarFile).toPath() - val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath())) - val handler = BaksmaliBaseDexExceptionHandler() - Dex2jar - .from(reader) - .withExceptionHandler(handler) - .reUseReg(false) - .topoLogicalSort() - .skipDebug(true) - .optimizeSynchronized(false) - .printIR(false) - .noCode(false) - .skipExceptions(false) - .to(jarFilePath) - if (handler.hasException()) { - val errorFile: Path = File(ApplicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt") - logger.error( - "Detail Error Information in File $errorFile\n" + - "Please report this file to one of following link if possible (any one).\n" + - " https://sourceforge.net/p/dex2jar/tickets/\n" + - " https://bitbucket.org/pxb1988/dex2jar/issues\n" + - " https://github.com/pxb1988/dex2jar/issues\n" + - " dex2jar@googlegroups.com" - ) - handler.dump(errorFile, emptyArray()) - } - } - - /** - * loads the extension main class called $className from the jar located at $jarPath - * It may return an instance of HttpSource or SourceFactory depending on the extension. - */ - fun loadExtensionInstance(jarPath: String, className: String): Any { - val classLoader = URLClassLoader(arrayOf(URL("file:$jarPath"))) - val classToLoad = Class.forName(className, true, classLoader) - return classToLoad.getDeclaredConstructor().newInstance() - } - data class InstallableAPK( val apkFilePath: String, val pkgName: String @@ -113,82 +70,104 @@ object Extension { val apkFilePath = fetcher() val apkName = Uri.parse(apkFilePath).lastPathSegment!! - // TODO: handle the whole apk signature, and trusting business - - val extensionRecord: ResultRow = transaction { - ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull() - } ?: { - transaction { - ExtensionTable.insert { - it[this.apkName] = apkName - } - ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()!! - } - }() - - val extensionId = extensionRecord[ExtensionTable.id] - // check if we don't have the extension already installed - if (!extensionRecord[ExtensionTable.isInstalled]) { + // if it's installed and we want to update, it first has to be uninstalled + val isInstalled = transaction { + ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull() + }?.get(ExtensionTable.isInstalled) ?: false + + if (!isInstalled) { val fileNameWithoutType = apkName.substringBefore(".apk") val dirPathWithoutType = "${ApplicationDirs.extensionsRoot}/$fileNameWithoutType" val jarFilePath = "$dirPathWithoutType.jar" val dexFilePath = "$dirPathWithoutType.dex" - val className: String = APKExtractor.extractDexAndReadClassname(apkFilePath, dexFilePath) + val packageInfo = getPackageInfo(apkFilePath) + val pkgName = packageInfo.packageName + + if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) { + throw Exception("This apk is not a tachiyomi extension") + } + + // Validate lib version + val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble() + if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { + throw Exception( + "Lib version is $libVersion, while only versions " + + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed" + ) + } + + val signatureHash = getSignatureHash(packageInfo) + + if (signatureHash == null) { + throw Exception("Package $pkgName isn't signed") + } else if (signatureHash !in trustedSignatures) { + // TODO: allow trusting keys + throw Exception("This apk is not a signed with the official tachiyomi signature") + } + + val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1" + + val className = packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS) + logger.debug("Main class for extension is $className") - dex2jar(dexFilePath, jarFilePath, fileNameWithoutType) + dex2jar(apkFilePath, jarFilePath, fileNameWithoutType) // clean up // File(apkFilePath).delete() File(dexFilePath).delete() - // update sources of the extension - val instance = loadExtensionInstance(jarFilePath, className) + // collect sources from the extension + val sources: List = when (val instance = loadExtensionSources(jarFilePath, className)) { + is Source -> listOf(instance) + is SourceFactory -> instance.createSources() - when (instance) { - is HttpSource -> { // single source - transaction { - if (SourceTable.select { SourceTable.id eq instance.id }.count() == 0L) { - SourceTable.insert { - it[id] = instance.id - it[name] = instance.name - it[lang] = instance.lang - it[extension] = extensionId - } - } - logger.debug("Installed source ${instance.name} with id ${instance.id}") - } - } - is SourceFactory -> { // theme source or multi lang - transaction { - instance.createSources().forEach { source -> - val httpSource = source as HttpSource - if (SourceTable.select { SourceTable.id eq httpSource.id }.count() == 0L) { - SourceTable.insert { - it[id] = httpSource.id - it[name] = httpSource.name - it[lang] = httpSource.lang - it[extension] = extensionId - it[partOfFactorySource] = true - } - } - logger.debug("Installed source ${httpSource.name} with id:${httpSource.id}") - } - } - } else -> { - throw RuntimeException("Extension content is unexpected") + throw RuntimeException("Unknown source class type! ${instance.javaClass}") } + }.map { it as CatalogueSource } + + val langs = sources.map { it.lang }.toSet() + val extensionLang = when (langs.size) { + 0 -> "" + 1 -> langs.first() + else -> "all" } + val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Tachiyomi: ") + // update extension info transaction { - ExtensionTable.update({ ExtensionTable.apkName eq apkName }) { - it[isInstalled] = true - it[classFQName] = className + if (ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) { + ExtensionTable.insert { + it[this.apkName] = apkName + it[name] = extensionName + it[this.pkgName] = packageInfo.packageName + it[versionName] = packageInfo.versionName + it[versionCode] = packageInfo.versionCode + it[lang] = extensionLang + it[this.isNsfw] = isNsfw + } + } + + ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) { + it[this.isInstalled] = true + it[this.classFQName] = className + } + + val extensionId = ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.firstOrNull()!![ExtensionTable.id].value + + sources.forEach { httpSource -> + SourceTable.insert { + it[id] = httpSource.id + it[name] = httpSource.name + it[lang] = httpSource.lang + it[extension] = extensionId + } + logger.debug("Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}") } } return 201 // we installed successfully diff --git a/server/src/main/kotlin/ir/armor/tachidesk/impl/util/APKExtractor.kt b/server/src/main/kotlin/ir/armor/tachidesk/impl/util/APKExtractor.kt deleted file mode 100644 index ec65c323..00000000 --- a/server/src/main/kotlin/ir/armor/tachidesk/impl/util/APKExtractor.kt +++ /dev/null @@ -1,250 +0,0 @@ -package ir.armor.tachidesk.impl.util - -/* -* Copyright (C) Contributors to the Suwayomi project -* -* This Source Code Form is subject to the terms of the Mozilla Public -* License, v. 2.0. If a copy of the MPL was not distributed with this -* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ - -import mu.KotlinLogging -import org.w3c.dom.Document -import org.xml.sax.InputSource -import java.io.IOException -import java.io.StringReader -import java.nio.file.Files -import java.nio.file.Paths -import java.util.zip.ZipFile -import javax.xml.parsers.DocumentBuilderFactory - -object APKExtractor { - private val logger = KotlinLogging.logger {} - - // decompressXML -- Parse the 'compressed' binary form of Android XML docs - // such as for AndroidManifest.xml in .apk files - private const val endDocTag = 0x00100101 - private const val startTag = 0x00100102 - private const val endTag = 0x00100103 - - private fun decompressXML(xml: ByteArray): String { - val finalXML = StringBuilder() - - // Compressed XML file/bytes starts with 24x bytes of data, - // 9 32 bit words in little endian order (LSB first): - // 0th word is 03 00 08 00 - // 3rd word SEEMS TO BE: Offset at then of StringTable - // 4th word is: Number of strings in string table - // WARNING: Sometime I indiscriminently display or refer to word in - // little endian storage format, or in integer format (ie MSB first). - val numbStrings = LEW(xml, 4 * 4) - - // StringIndexTable starts at offset 24x, an array of 32 bit LE offsets - // of the length/string data in the StringTable. - val sitOff = 0x24 // Offset of start of StringIndexTable - - // StringTable, each string is represented with a 16 bit little endian - // character count, followed by that number of 16 bit (LE) (Unicode) - // chars. - val stOff = sitOff + numbStrings * 4 // StringTable follows - // StrIndexTable - - // XMLTags, The XML tag tree starts after some unknown content after the - // StringTable. There is some unknown data after the StringTable, scan - // forward from this point to the flag for the start of an XML start - // tag. - var xmlTagOff = LEW(xml, 3 * 4) // Start from the offset in the 3rd - // word. - // Scan forward until we find the bytes: 0x02011000(x00100102 in normal - // int) - var ii = xmlTagOff - while (ii < xml.size - 4) { - if (LEW(xml, ii) == startTag) { - xmlTagOff = ii - break - } - ii += 4 - } - - // XML tags and attributes: - // Every XML start and end tag consists of 6 32 bit words: - // 0th word: 02011000 for startTag and 03011000 for endTag - // 1st word: a flag?, like 38000000 - // 2nd word: Line of where this tag appeared in the original source file - // 3rd word: FFFFFFFF ?? - // 4th word: StringIndex of NameSpace name, or FFFFFFFF for default NS - // 5th word: StringIndex of Element Name - // (Note: 01011000 in 0th word means end of XML document, endDocTag) - - // Start tags (not end tags) contain 3 more words: - // 6th word: 14001400 meaning?? - // 7th word: Number of Attributes that follow this tag(follow word 8th) - // 8th word: 00000000 meaning?? - - // Attributes consist of 5 words: - // 0th word: StringIndex of Attribute Name's Namespace, or FFFFFFFF - // 1st word: StringIndex of Attribute Name - // 2nd word: StringIndex of Attribute Value, or FFFFFFF if ResourceId - // used - // 3rd word: Flags? - // 4th word: str ind of attr value again, or ResourceId of value - - // TMP, dump string table to tr for debugging - // tr.addSelect("strings", null); - // for (int ii=0; ii") - prtIndent(indent, "<$name$sb>") - indent++ - } else if (tag0 == endTag) { // XML END TAG - indent-- - off += 6 * 4 // Skip over 6 words of endTag data - val name = compXmlString(xml, sitOff, stOff, nameSi) - finalXML.append("") - prtIndent( - indent, - " (line " + startTagLineNo + - "-" + lineNo + ")" - ) - // tr.parent(); // Step back up the NobTree - } else if (tag0 == endDocTag) { // END OF XML DOC TAG - break - } else { - logger.debug( - " Unrecognized tag code '${Integer.toHexString(tag0)}'' at offset $off" - ) - break - } - } // end of while loop scanning tags and attributes of XML tree - logger.debug(" end at offset $off") - return finalXML.toString() - } // end of decompressXML - - private fun compXmlString(xml: ByteArray, sitOff: Int, stOff: Int, strInd: Int): String? { - if (strInd < 0) return null - val strOff = stOff + LEW(xml, sitOff + strInd * 4) - return compXmlStringAt(xml, strOff) - } - - private var spaces = " " - private fun prtIndent(indent: Int, str: String) { - logger.debug(spaces.substring(0, Math.min(indent * 2, spaces.length)) + str) - } - - // compXmlStringAt -- Return the string stored in StringTable format at - // offset strOff. This offset points to the 16 bit string length, which - // is followed by that number of 16 bit (Unicode) chars. - private fun compXmlStringAt(arr: ByteArray, strOff: Int): String { - val strLen: Int = arr[strOff + 1].toInt() shl 8 and 0xff00 or arr[strOff].toInt() and 0xff - val chars = ByteArray(strLen) - for (ii in 0 until strLen) { - chars[ii] = arr[strOff + 2 + ii * 2] - } - return String(chars) // Hack, just use 8 byte chars - } // end of compXmlStringAt - - // LEW -- Return value of a Little Endian 32 bit word from the byte array - // at offset off. - private fun LEW(arr: ByteArray, off: Int): Int { - - return (arr[off + 3].toInt() shl 24) and -0x1000000 or - (arr[off + 2].toInt() shl 16 and 0xff0000) or - (arr[off + 1].toInt() shl 8 and 0xff00) or - (arr[off].toInt() and 0xFF) - } // end of LEW - - @Throws(Exception::class) - private fun loadXMLFromString(xml: String?): Document { - return DocumentBuilderFactory.newInstance() - .newDocumentBuilder() - .parse(InputSource(StringReader(xml))) - } - - @Throws(IOException::class) - fun extractDexAndReadClassname(filePath: String, dexPath: String): String { - ZipFile(filePath).use { zip -> - val androidManifest = zip.getEntry("AndroidManifest.xml") - val classesDex = zip.getEntry("classes.dex") - - // write dex file - zip.getInputStream(classesDex).use { dexInputStream -> - Files.newOutputStream(Paths.get(dexPath)).use { fileOutputStream -> - dexInputStream.copyTo(fileOutputStream) - } - } - - // read xml file - val xml = zip.getInputStream(androidManifest).use { inpStream -> - // 1024000 = 100 kb - ByteArray(1024000).let { - inpStream.read(it) - decompressXML(it) - } - } - try { - val xmlDoc = loadXMLFromString(xml) - val pkg = xmlDoc.documentElement.getAttribute("package") - val nodes = xmlDoc.getElementsByTagName("meta-data") - for (i in 0 until nodes.length) { - val attributes = nodes.item(i).attributes - logger.debug(attributes.getNamedItem("name").nodeValue) - if (attributes.getNamedItem("name").nodeValue == "tachiyomi.extension.class") { - return pkg + attributes.getNamedItem("value").nodeValue - } - } - } catch (e: Exception) { - e.printStackTrace() - } - return "" - } - } -} diff --git a/server/src/main/kotlin/ir/armor/tachidesk/impl/util/GetHttpSource.kt b/server/src/main/kotlin/ir/armor/tachidesk/impl/util/GetHttpSource.kt index a20e31c2..5ed05c38 100644 --- a/server/src/main/kotlin/ir/armor/tachidesk/impl/util/GetHttpSource.kt +++ b/server/src/main/kotlin/ir/armor/tachidesk/impl/util/GetHttpSource.kt @@ -9,7 +9,7 @@ package ir.armor.tachidesk.impl.util import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.online.HttpSource -import ir.armor.tachidesk.impl.Extension +import ir.armor.tachidesk.impl.util.PackageTools.loadExtensionSources import ir.armor.tachidesk.model.database.ExtensionTable import ir.armor.tachidesk.model.database.SourceTable import ir.armor.tachidesk.server.ApplicationDirs @@ -40,7 +40,7 @@ object GetHttpSource { val jarName = apkName.substringBefore(".apk") + ".jar" val jarPath = "${ApplicationDirs.extensionsRoot}/$jarName" - val extensionInstance = Extension.loadExtensionInstance(jarPath, className) + val extensionInstance = loadExtensionSources(jarPath, className) if (sourceRecord[SourceTable.partOfFactorySource]) { (extensionInstance as SourceFactory).createSources().forEach { diff --git a/server/src/main/kotlin/ir/armor/tachidesk/impl/util/PackageTools.kt b/server/src/main/kotlin/ir/armor/tachidesk/impl/util/PackageTools.kt new file mode 100644 index 00000000..205633c2 --- /dev/null +++ b/server/src/main/kotlin/ir/armor/tachidesk/impl/util/PackageTools.kt @@ -0,0 +1,137 @@ +package ir.armor.tachidesk.impl.util + +import android.content.pm.PackageInfo +import android.content.pm.Signature +import android.os.Bundle +import com.googlecode.d2j.dex.Dex2jar +import com.googlecode.d2j.reader.MultiDexFileReader +import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler +import eu.kanade.tachiyomi.util.lang.Hash +import ir.armor.tachidesk.server.ApplicationDirs +import mu.KotlinLogging +import net.dongliu.apk.parser.ApkFile +import net.dongliu.apk.parser.ApkParsers +import org.w3c.dom.Element +import org.w3c.dom.Node +import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList +import xyz.nulldev.androidcompat.pm.toPackageInfo +import java.io.File +import java.net.URL +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import javax.xml.parsers.DocumentBuilderFactory + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +object PackageTools { + private val logger = KotlinLogging.logger {} + + const val EXTENSION_FEATURE = "tachiyomi.extension" + const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" + const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" + const val METADATA_NSFW = "tachiyomi.extension.nsfw" + const val LIB_VERSION_MIN = 1.2 + const val LIB_VERSION_MAX = 1.2 + + // inorichi's key + private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" + var trustedSignatures = mutableSetOf() + officialSignature + + /** + * Convert dex to jar, a wrapper for the dex2jar library + */ + fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) { + // adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine + // source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java + + val jarFilePath = File(jarFile).toPath() + val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath())) + val handler = BaksmaliBaseDexExceptionHandler() + Dex2jar + .from(reader) + .withExceptionHandler(handler) + .reUseReg(false) + .topoLogicalSort() + .skipDebug(true) + .optimizeSynchronized(false) + .printIR(false) + .noCode(false) + .skipExceptions(false) + .to(jarFilePath) + if (handler.hasException()) { + val errorFile: Path = File(ApplicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt") + logger.error( + "Detail Error Information in File $errorFile\n" + + "Please report this file to one of following link if possible (any one).\n" + + " https://sourceforge.net/p/dex2jar/tickets/\n" + + " https://bitbucket.org/pxb1988/dex2jar/issues\n" + + " https://github.com/pxb1988/dex2jar/issues\n" + + " dex2jar@googlegroups.com" + ) + handler.dump(errorFile, emptyArray()) + } + } + + /** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */ + fun getPackageInfo(apkFilePath: String): PackageInfo { + val apk = File(apkFilePath) + return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply { + val parsed = ApkFile(apk) + val dbFactory = DocumentBuilderFactory.newInstance() + val dBuilder = dbFactory.newDocumentBuilder() + val doc = parsed.manifestXml.byteInputStream().use { + dBuilder.parse(it) + } + + logger.debug(parsed.manifestXml) + + applicationInfo.metaData = Bundle().apply { + val appTag = doc.getElementsByTagName("application").item(0) + + appTag?.childNodes?.toList()?.filter { + it.nodeType == Node.ELEMENT_NODE + }?.map { + it as Element + }?.filter { + it.tagName == "meta-data" + }?.map { + putString( + it.attributes.getNamedItem("android:name").nodeValue, + it.attributes.getNamedItem("android:value").nodeValue + ) + } + } + + signatures = ( + parsed.apkSingers.flatMap { it.certificateMetas } + /*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/ + ) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72 + .map { Signature(it.data) }.toTypedArray() + } + } + + fun getSignatureHash(pkgInfo: PackageInfo): String? { + val signatures = pkgInfo.signatures + return if (signatures != null && signatures.isNotEmpty()) { + Hash.sha256(signatures.first().toByteArray()) + } else { + null + } + } + + /** + * loads the extension main class called $className from the jar located at $jarPath + * It may return an instance of HttpSource or SourceFactory depending on the extension. + */ + fun loadExtensionSources(jarPath: String, className: String): Any { + val classLoader = URLClassLoader(arrayOf(URL("file:$jarPath"))) + val classToLoad = Class.forName(className, false, classLoader) + return classToLoad.getDeclaredConstructor().newInstance() + } +} diff --git a/server/src/main/kotlin/ir/armor/tachidesk/model/database/ExtensionTable.kt b/server/src/main/kotlin/ir/armor/tachidesk/model/database/ExtensionTable.kt index bd870791..cf2268ba 100644 --- a/server/src/main/kotlin/ir/armor/tachidesk/model/database/ExtensionTable.kt +++ b/server/src/main/kotlin/ir/armor/tachidesk/model/database/ExtensionTable.kt @@ -16,15 +16,16 @@ object ExtensionTable : IntIdTable() { val iconUrl = varchar("icon_url", 2048) .default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp") - val name = varchar("name", 128).nullable().default(null) - val pkgName = varchar("pkg_name", 128).nullable().default(null) - val versionName = varchar("version_name", 16).nullable().default(null) - val versionCode = integer("version_code").default(0) - val lang = varchar("lang", 10).nullable().default(null) - val isNsfw = bool("is_nsfw").nullable().default(null) + val name = varchar("name", 128) + val pkgName = varchar("pkg_name", 128) + val versionName = varchar("version_name", 16) + val versionCode = integer("version_code") + val lang = varchar("lang", 3) + val isNsfw = bool("is_nsfw") val isInstalled = bool("is_installed").default(false) val hasUpdate = bool("has_update").default(false) val isObsolete = bool("is_obsolete").default(false) + val classFQName = varchar("class_name", 1024).default("") // fully qualified name } diff --git a/server/src/main/kotlin/ir/armor/tachidesk/model/dataclass/ExtensionDataClass.kt b/server/src/main/kotlin/ir/armor/tachidesk/model/dataclass/ExtensionDataClass.kt index dfc19f5b..d0cf103b 100644 --- a/server/src/main/kotlin/ir/armor/tachidesk/model/dataclass/ExtensionDataClass.kt +++ b/server/src/main/kotlin/ir/armor/tachidesk/model/dataclass/ExtensionDataClass.kt @@ -10,12 +10,14 @@ package ir.armor.tachidesk.model.dataclass data class ExtensionDataClass( val apkName: String, val iconUrl: String, - val name: String?, - val pkgName: String?, - val versionName: String?, - val versionCode: Int?, - val lang: String?, - val isNsfw: Boolean?, + + val name: String, + val pkgName: String, + val versionName: String, + val versionCode: Int, + val lang: String, + val isNsfw: Boolean, + val installed: Boolean, val hasUpdate: Boolean, val obsolete: Boolean,