diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7190dbb0..34fb4d3a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -110,6 +110,7 @@ dex2jar-tools = { module = "de.femtopedia.dex2jar:dex-tools", version.ref = "dex # APK apk-parser = "net.dongliu:apk-parser:2.6.10" apksig = "com.android.tools.build:apksig:9.2.1" +axml = "com.github.Aliucord:axml:ed26565eb0" # Xml xmlpull = "xmlpull:xmlpull:1.1.3.4a" @@ -215,6 +216,7 @@ shared = [ "dex2jar-translator", "dex2jar-tools", "apk-parser", + "axml", "jackson-annotations", "jcef", ] diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt index be0ab67f..876bb7dd 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/queries/ExtensionQuery.kt @@ -103,6 +103,7 @@ class ExtensionQuery { val name: String? = null, val pkgName: String? = null, val apkUrl: String? = null, + val jarUrl: String? = null, val extensionLib: String? = null, val versionName: String? = null, val versionCode: Int? = null, @@ -122,6 +123,7 @@ class ExtensionQuery { opAnd.eq(apkName, ExtensionTable.apkName) opAnd.eq(iconUrl, ExtensionTable.iconUrl) opAnd.eq(apkUrl, ExtensionTable.apkUrl) + opAnd.eq(jarUrl, ExtensionTable.jarUrl) opAnd.eq(name, ExtensionTable.name) opAnd.eq(extensionLib, ExtensionTable.extensionLib) opAnd.eq(versionName, ExtensionTable.versionName) @@ -150,6 +152,7 @@ class ExtensionQuery { val name: StringFilter? = null, val pkgName: StringFilter? = null, val apkUrl: StringFilter? = null, + val jarUrl: StringFilter? = null, val versionName: StringFilter? = null, val extensionLib: StringFilter? = null, @GraphQLDeprecated("", ReplaceWith("versionCodeLong")) @@ -175,6 +178,7 @@ class ExtensionQuery { andFilterWithCompareString(ExtensionTable.name, name), andFilterWithCompareString(ExtensionTable.pkgName, pkgName), andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl), + andFilterWithCompareString(ExtensionTable.jarUrl, jarUrl), andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib), andFilterWithCompareString(ExtensionTable.versionName, versionName), andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong), diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ExtensionType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ExtensionType.kt index 35dc9b4d..749a2ef8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ExtensionType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/ExtensionType.kt @@ -32,6 +32,7 @@ class ExtensionType( val name: String, val pkgName: String, val apkUrl: String?, + val jarUrl: String?, val extensionLib: String?, val versionName: String, @GraphQLDeprecated( @@ -56,6 +57,7 @@ class ExtensionType( name = row[ExtensionTable.name], pkgName = row[ExtensionTable.pkgName], apkUrl = row[ExtensionTable.apkUrl], + jarUrl = row[ExtensionTable.jarUrl], extensionLib = row[ExtensionTable.extensionLib], versionName = row[ExtensionTable.versionName], versionCode = row[ExtensionTable.versionCode].toInt(), diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt index 9630bf6d..ecc12a25 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/Extension.kt @@ -20,6 +20,7 @@ import okhttp3.CacheControl import okio.buffer import okio.sink import okio.source +import org.apache.commons.compress.archivers.zip.ZipFile import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert @@ -27,6 +28,7 @@ import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update +import suwayomi.tachidesk.manga.impl.util.AndroidManifestParser import suwayomi.tachidesk.manga.impl.util.PackageTools import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX @@ -39,6 +41,7 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources +import suwayomi.tachidesk.manga.impl.util.ResourceArscIconParser import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.source.GetSource import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage @@ -48,15 +51,30 @@ import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.server.ApplicationDirs import uy.kohesive.injekt.injectLazy -import java.io.File -import java.io.FileOutputStream import java.io.InputStream +import java.nio.file.Path import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream +import kotlin.collections.any +import kotlin.collections.orEmpty +import kotlin.io.inputStream +import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.Path import kotlin.io.path.absolutePathString +import kotlin.io.path.copyTo +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.deleteRecursively +import kotlin.io.path.div +import kotlin.io.path.exists +import kotlin.io.path.inputStream +import kotlin.io.path.isRegularFile +import kotlin.io.path.name +import kotlin.io.path.nameWithoutExtension import kotlin.io.path.outputStream +import kotlin.io.path.relativeTo +import kotlin.io.path.walk object Extension { private val logger = KotlinLogging.logger {} @@ -64,37 +82,52 @@ object Extension { suspend fun installExtension(pkgName: String): Int { logger.debug { "Installing $pkgName" } - val apkUrl = + val extension = transaction { ExtensionTable - .select(ExtensionTable.apkUrl) + .select(ExtensionTable.apkUrl, ExtensionTable.jarUrl) .where { ExtensionTable.pkgName eq pkgName } .firstOrNull() - ?.get(ExtensionTable.apkUrl) - } ?: throw NullPointerException("Could not find extension $pkgName") + } ?: throw NullPointerException("Could not find extension for $pkgName") + val jarUrl = extension[ExtensionTable.jarUrl] + val apkUrl = extension[ExtensionTable.apkUrl] + return when { + jarUrl != null -> { + installJAR { + val jarName = Uri.parse(jarUrl).lastPathSegment!! + val jarSavePath = "${applicationDirs.extensionsRoot}/$jarName" + // download jar file + downloadExtension(jarUrl, jarSavePath) - return installAPK { - val apkName = Uri.parse(apkUrl).lastPathSegment!! - val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName" - // download apk file - downloadAPKFile(apkUrl, apkSavePath) + jarSavePath + } + } + apkUrl != null -> { + installAPK { + val apkName = Uri.parse(apkUrl).lastPathSegment!! + val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName" + // download apk file + downloadExtension(apkUrl, apkSavePath) - apkSavePath + apkSavePath + } + } + else -> throw NullPointerException("Could not find extension url for $pkgName") } } suspend fun installExternalExtension( inputStream: InputStream, - apkName: String, - ): Int = - installAPK(true) { + extensionName: String, + ): Int { + val copyToExtensionsRoot = { val rootPath = Path(applicationDirs.extensionsRoot) - val downloadedFile = rootPath.resolve(apkName).normalize() + val downloadedFile = rootPath.resolve(extensionName).normalize() check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) { - "File '$apkName' is not a valid extension file" + "File '$extensionName' is not a valid extension file" } - logger.debug { "Saving apk at $apkName" } - // download apk file + logger.debug { "Saving jar at $extensionName" } + // download jar file downloadedFile.outputStream().sink().buffer().use { sink -> inputStream.source().use { source -> sink.writeAll(source) @@ -104,26 +137,30 @@ object Extension { downloadedFile.absolutePathString() } + return when { + extensionName.endsWith(".jar") -> installJAR(true, copyToExtensionsRoot) + extensionName.endsWith(".apk") -> installAPK(true, copyToExtensionsRoot) + else -> throw NullPointerException("Could not find extension type for $extensionName") + } + } + suspend fun installAPK( forceReinstall: Boolean = false, fetcher: suspend () -> String, ): Int { - val apkFilePath = fetcher() - val apkName = File(apkFilePath).name + val apkFile = Path(fetcher()) // check if we don't have the extension already installed // if it's installed and we want to update, it first has to be uninstalled val isInstalled = transaction { - ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.firstOrNull() + ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkFile.name }.firstOrNull() }?.get(ExtensionTable.isInstalled) ?: false - val fileNameWithoutType = apkName.substringBefore(".apk") + val dirPathWithoutType = "${applicationDirs.extensionsRoot}/${apkFile.nameWithoutExtension}" + val jarFile = Path("$dirPathWithoutType.jar") - val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType" - val jarFilePath = "$dirPathWithoutType.jar" - - val packageInfo = getPackageInfo(apkFilePath) + val packageInfo = getPackageInfo(apkFile) val pkgName = packageInfo.packageName if (isInstalled && forceReinstall) { uninstallExtension(pkgName) @@ -144,13 +181,13 @@ object Extension { } // TODO: allow trusting keys -// val signatureHash = getSignatureHash(packageInfo) - -// if (signatureHash == null) { -// throw Exception("Package $pkgName isn't signed") -// } else if (signatureHash !in trustedSignatures) { -// throw Exception("This apk is not a signed with the official tachiyomi signature") -// } + // val signatureHash = getSignatureHash(packageInfo) + // + // if (signatureHash == null) { + // throw Exception("Package $pkgName isn't signed") + // } else if (signatureHash !in trustedSignatures) { + // throw Exception("This apk is not a signed with the official tachiyomi signature") + // } var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING) if (contentWarning == 0) { @@ -180,31 +217,14 @@ object Extension { logger.debug { "Main class for extension is $className" } - dex2jar(apkFilePath, jarFilePath, fileNameWithoutType) - extractAssetsFromApk(apkFilePath, jarFilePath) - extractAndCacheApkIcon(apkFilePath, packageInfo.packageName) + dex2jar(apkFile, jarFile) + extractAssetsFromApk(apkFile, jarFile) + extractAndCacheApkIcon(apkFile, packageInfo.packageName) // clean up - File(apkFilePath).delete() + apkFile.deleteIfExists() try { - // collect sources from the extension - val extensionMainClassInstance = loadExtensionSources(jarFilePath, className) - val sources: List = - when (extensionMainClassInstance) { - is Source -> listOf(extensionMainClassInstance) - is SourceFactory -> extensionMainClassInstance.createSources() - else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}") - } - - val langs = sources.map { it.lang }.toSet() - val extensionLang = - when (langs.size) { - 0 -> "" - 1 -> langs.first() - else -> "all" - } - val extensionName = packageInfo.applicationInfo.metaData.getString(METADATA_NAME) ?: packageInfo.applicationInfo.nonLocalizedLabel @@ -217,52 +237,23 @@ object Extension { .takeUnless { it == "0" } ?: packageInfo.versionName.substringBeforeLast('.') - // update extension info - transaction { - if (ExtensionTable.selectAll().where { 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.toLong() - it[extensionLib] = extensionLibVersion - it[lang] = extensionLang - it[this.contentWarning] = contentWarning - } - } + setupJar( + jarFile, + className, + extensionName, + extensionLibVersion, + apkFile.name, + packageInfo.packageName, + packageInfo.versionName, + packageInfo.versionCode, + contentWarning + ) - ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) { - it[this.apkName] = apkName - it[this.isInstalled] = true - it[this.classFQName] = className - it[versionName] = packageInfo.versionName - it[versionCode] = packageInfo.versionCode.toLong() - } - - val extensionId = - ExtensionTable - .selectAll() - .where { ExtensionTable.pkgName eq pkgName } - .first()[ExtensionTable.id] - .value - - sources.forEach { httpSource -> - SourceTable.insert { - it[id] = httpSource.id - it[name] = httpSource.name - it[lang] = httpSource.lang - it[extension] = extensionId - it[this.contentWarning] = contentWarning - } - logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" } - } - } return 201 // we installed successfully } catch (e: Throwable) { // free up the file descriptor if exists - PackageTools.jarLoaderMap.remove(jarFilePath)?.close() - File(jarFilePath).delete() + PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close() + jarFile.deleteIfExists() try { uninstallExtension(pkgName) @@ -275,14 +266,204 @@ object Extension { } } + suspend fun installJAR( + forceReinstall: Boolean = false, + fetcher: suspend () -> String, + ): Int { + val jarFile = Path(fetcher()) + + // check if we don't have the extension already installed + // if it's installed and we want to update, it first has to be uninstalled + val isInstalled = + transaction { + ExtensionTable.selectAll().where { ExtensionTable.apkName eq (jarFile.nameWithoutExtension + ".apk") }.firstOrNull() + }?.get(ExtensionTable.isInstalled) ?: false + + val jarZip = ZipFile.builder() + .setPath(jarFile) + .get() + + return jarZip.use { jarZip -> + val manifest = jarZip.getInputStream(jarZip.getEntry("AndroidManifest.xml")) + .use { + AndroidManifestParser.parse(it) + } + + val pkgName = manifest.packageName + if (isInstalled && forceReinstall) { + uninstallExtension(pkgName) + } + + if (!isInstalled || forceReinstall) { + if (!manifest.usesFeatures.any { it.name == EXTENSION_FEATURE }) { + throw Exception("This apk is not a Tachiyomi extension") + } + + // Validate lib version + val libVersion = manifest.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", + ) + } + + // TODO: allow trusting keys + // val signatureHash = getSignatureHash(packageInfo) + // + // if (signatureHash == null) { + // throw Exception("Package $pkgName isn't signed") + // } else if (signatureHash !in trustedSignatures) { + // throw Exception("This apk is not a signed with the official tachiyomi signature") + // } + + fun List.getString(name: String): String? { + return this.find { it.name == name }?.value + } + + var contentWarning = manifest.application!!.metaData.getString(METADATA_CONTENT_WARNING) + ?.toIntOrNull() + if (contentWarning == null) { + contentWarning = manifest.application.metaData.getString(METADATA_NSFW) + ?.toIntOrNull() + ?: 0 + } + + val sourceClass = + manifest.application.metaData.getString(METADATA_SOURCE_CLASS)!! + .trim() + + val className = + if (sourceClass.startsWith(".")) { + pkgName + sourceClass + } else { + sourceClass + } + + logger.debug { "Main class for extension is $className" } + + extractAndCacheJarIcon(jarZip, pkgName) + + try { + val extensionName = + manifest.application.metaData.getString(METADATA_NAME) + ?: manifest.application.label!! + .substringAfter("Tachiyomi: ") + + val extensionLibVersion = + manifest.application.metaData + .getString(METADATA_EXTENSION_LIB) + .takeUnless { it == "0" } + ?: manifest.versionName.substringBeforeLast('.') + + setupJar( + jarFile, + className, + extensionName, + extensionLibVersion, + jarFile.name.removeSuffix(".jar") + ".apk", + manifest.packageName, + manifest.versionName, + manifest.versionCode!!, + contentWarning + ) + + return@use 201 // we installed successfully + } catch (e: Throwable) { + // free up the file descriptor if exists + PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close() + jarFile.deleteIfExists() + + try { + uninstallExtension(pkgName) + } catch (_: Exception) {} + throw e + } + } else { + return@use 302 // extension was already installed + } + } + } + + private fun setupJar( + jarFile: Path, + className: String, + extensionName: String, + extensionLibVersion: String, + apkName: String, + pkgName: String, + versionName: String, + versionCode: Int, + contentWarning: Int, + ) { + // collect sources from the extension + val extensionMainClassInstance = loadExtensionSources(jarFile, className) + val sources: List = + when (extensionMainClassInstance) { + is Source -> listOf(extensionMainClassInstance) + is SourceFactory -> extensionMainClassInstance.createSources() + else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}") + } + + val langs = sources.map { it.lang }.toSet() + val extensionLang = + when (langs.size) { + 0 -> "" + 1 -> langs.first() + else -> "all" + } + + + // update extension info + transaction { + if (ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) { + ExtensionTable.insert { + it[this.apkName] = apkName + it[name] = extensionName + it[this.pkgName] = pkgName + it[this.versionName] = versionName + it[this.versionCode] = versionCode.toLong() + it[extensionLib] = extensionLibVersion + it[lang] = extensionLang + it[this.contentWarning] = contentWarning + } + } + + ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) { + it[this.apkName] = apkName + it[this.isInstalled] = true + it[this.classFQName] = className + it[this.versionName] = versionName + it[this.versionCode] = versionCode.toLong() + } + + val extensionId = + ExtensionTable + .selectAll() + .where { ExtensionTable.pkgName eq pkgName } + .first()[ExtensionTable.id] + .value + + sources.forEach { httpSource -> + SourceTable.insert { + it[id] = httpSource.id + it[name] = httpSource.name + it[lang] = httpSource.lang + it[extension] = extensionId + it[this.contentWarning] = contentWarning + } + logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" } + } + } + } + private fun extractAndCacheApkIcon( - apkFilePath: String, + apkFile: Path, pkgName: String, ) { - val iconCacheDir = "${applicationDirs.extensionsRoot}/icon" try { val iconData = - ApkFile(File(apkFilePath)).use { apk -> + ApkFile(apkFile.toFile()).use { apk -> apk.allIcons .filterIsInstance() .mapNotNull { it.data?.let { data -> data to it.density } } @@ -293,31 +474,48 @@ object Extension { logger.warn { "No icon found in APK $pkgName" } return } - - File(iconCacheDir).mkdirs() - clearCachedImage(iconCacheDir, pkgName) - saveImage("$iconCacheDir/$pkgName", iconData.inputStream(), null) + cacheIcon(pkgName, iconData.inputStream()) } catch (e: Exception) { logger.warn(e) { "Failed to extract icon from APK $pkgName" } } } - private fun extractAssetsFromApk( - apkPath: String, - jarPath: String, + private fun extractAndCacheJarIcon( + zipFile: ZipFile, + pkgName: String, ) { - val apkFile = File(apkPath) - val jarFile = File(jarPath) + try { + val iconStream = ResourceArscIconParser.extractIcon(zipFile) + cacheIcon(pkgName, iconStream) + } catch (e: Exception) { + logger.warn(e) { "Failed to extract icon from JAR $pkgName" } + } + } - val assetsFolder = File("${apkFile.parent}/${apkFile.nameWithoutExtension}_assets") - assetsFolder.mkdir() + private fun cacheIcon( + pkgName: String, + inputStream: InputStream, + ) { + val iconCacheDir = Path("${applicationDirs.extensionsRoot}/icon") + iconCacheDir.createDirectories() + clearCachedImage(iconCacheDir.absolutePathString(), pkgName) + saveImage("$iconCacheDir/$pkgName", inputStream, null) + } + + @OptIn(ExperimentalPathApi::class) + private fun extractAssetsFromApk( + apkFile: Path, + jarFile: Path, + ) { + val assetsFolder = apkFile.parent / "${apkFile.nameWithoutExtension}_assets" + assetsFolder.createDirectories() ZipInputStream(apkFile.inputStream()).use { zipInputStream -> var zipEntry = zipInputStream.nextEntry while (zipEntry != null) { if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) { - val assetFile = File(assetsFolder, zipEntry.name) - assetFile.parentFile.mkdirs() - FileOutputStream(assetFile).use { outputStream -> + val assetFile = assetsFolder / zipEntry.name + assetFile.parent.createDirectories() + assetFile.outputStream().use { outputStream -> zipInputStream.copyTo(outputStream) } } @@ -325,9 +523,9 @@ object Extension { } } - val tempJarFile = File("${jarFile.parent}/${jarFile.nameWithoutExtension}_temp.jar") + val tempJarFile = jarFile.parent / "${jarFile.nameWithoutExtension}_temp.jar" ZipInputStream(jarFile.inputStream()).use { jarZipInputStream -> - ZipOutputStream(FileOutputStream(tempJarFile)).use { jarZipOutputStream -> + ZipOutputStream(tempJarFile.outputStream()).use { jarZipOutputStream -> var zipEntry = jarZipInputStream.nextEntry while (zipEntry != null) { if (!zipEntry.name.startsWith("META-INF/")) { @@ -336,8 +534,8 @@ object Extension { } zipEntry = jarZipInputStream.nextEntry } - assetsFolder.walkTopDown().forEach { file -> - if (file.isFile) { + assetsFolder.walk().forEach { file -> + if (file.isRegularFile()) { jarZipOutputStream.putNextEntry(ZipEntry(file.relativeTo(assetsFolder).toString().replace("\\", "/"))) file.inputStream().use { inputStream -> inputStream.copyTo(jarZipOutputStream) @@ -348,15 +546,16 @@ object Extension { } } - jarFile.delete() - tempJarFile.renameTo(jarFile) + jarFile.deleteIfExists() + tempJarFile.copyTo(jarFile) + tempJarFile.deleteIfExists() assetsFolder.deleteRecursively() } private val network: NetworkHelper by injectLazy() - private suspend fun downloadAPKFile( + private suspend fun downloadExtension( url: String, savePath: String, ) { @@ -366,11 +565,10 @@ object Extension { GET(url, cache = CacheControl.FORCE_NETWORK), ).await() - val downloadedFile = File(savePath) - downloadedFile.sink().buffer().use { sink -> - response.body.source().use { source -> - sink.writeAll(source) - sink.flush() + val downloadedFile = Path(savePath) + response.body.byteStream().use { + downloadedFile.outputStream().buffered().use { out -> + it.copyTo(out) } } } @@ -382,7 +580,7 @@ object Extension { val fileNameWithoutType = extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk") ?: throw NullPointerException("Missing $pkgName apkName") - val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar" + val jarPath = Path(applicationDirs.extensionsRoot) / "$fileNameWithoutType.jar" val sources = transaction { val extensionId = extensionRecord[ExtensionTable.id].value @@ -404,14 +602,14 @@ object Extension { sources } - if (File(jarPath).exists()) { + if (jarPath.exists()) { // free up the file descriptor if exists - PackageTools.jarLoaderMap.remove(jarPath)?.close() + PackageTools.jarLoaderMap.remove(jarPath.absolutePathString())?.close() // clear all loaded sources sources.forEach { GetSource.unregisterSource(it) } - File(jarPath).delete() + jarPath.deleteIfExists() } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt index 6d53429e..20818aea 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/ExtensionsList.kt @@ -133,6 +133,7 @@ object ExtensionsList { this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl this[ExtensionTable.apkUrl] = foundExtension.apkUrl + this[ExtensionTable.jarUrl] = foundExtension.jarUrl // add these because batch updates need matching columns this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate] @@ -176,6 +177,7 @@ object ExtensionsList { this[ExtensionTable.lang] = foundExtension.lang this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal this[ExtensionTable.apkUrl] = foundExtension.apkUrl + this[ExtensionTable.jarUrl] = foundExtension.jarUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl } }.toExecutable() @@ -193,6 +195,7 @@ object ExtensionsList { this[ExtensionTable.lang] = foundExtension.lang this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal this[ExtensionTable.apkUrl] = foundExtension.apkUrl + this[ExtensionTable.jarUrl] = foundExtension.jarUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/github/NetworkExtensionStore.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/github/NetworkExtensionStore.kt index 1db92197..2613a9b9 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/github/NetworkExtensionStore.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/github/NetworkExtensionStore.kt @@ -53,6 +53,8 @@ data class NetworkExtensionStore( data class Resources( @ProtoNumber(1) val apkUrl: String, @ProtoNumber(2) val iconUrl: String, + // Keiyoushi specific output + @ProtoNumber(501) val jarUrl: String? = null, ) @Serializable @@ -109,6 +111,7 @@ fun NetworkExtensionStore.ExtensionList.toExtensionInfos(store: ExtensionStore): name = extension.name, pkgName = extension.packageName, apkUrl = extension.resources.apkUrl, + jarUrl = extension.resources.jarUrl, iconUrl = extension.resources.iconUrl, extensionLib = extension.extensionLib, versionCode = extension.versionCode, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/github/NetworkLegacyExtension.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/github/NetworkLegacyExtension.kt index bffa2bc6..e5ca5734 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/github/NetworkLegacyExtension.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/extension/github/NetworkLegacyExtension.kt @@ -44,6 +44,7 @@ fun NetworkLegacyExtension.toExtensionInfo( name = name.substringAfter("Tachiyomi: "), pkgName = pkg, apkUrl = "$storeBaseUrl/apk/$apk", + jarUrl = null, iconUrl = "$storeBaseUrl/icon/$pkg.png", extensionLib = version.substringBeforeLast('.'), versionCode = code, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/AndroidManifestParser.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/AndroidManifestParser.kt new file mode 100644 index 00000000..fc419cf4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/AndroidManifestParser.kt @@ -0,0 +1,171 @@ +package suwayomi.tachidesk.manga.impl.util + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi +import nl.adaptivity.xmlutil.QName +import nl.adaptivity.xmlutil.XmlDeclMode +import nl.adaptivity.xmlutil.XmlReader +import nl.adaptivity.xmlutil.core.KtXmlReader +import nl.adaptivity.xmlutil.serialization.InputKind +import nl.adaptivity.xmlutil.serialization.UnknownChildHandler +import nl.adaptivity.xmlutil.serialization.XML +import nl.adaptivity.xmlutil.serialization.XmlElement +import nl.adaptivity.xmlutil.serialization.XmlSerialName +import nl.adaptivity.xmlutil.serialization.structure.XmlDescriptor +import java.io.InputStream + +object AndroidManifestParser { + private const val ANDROID_NS = "http://schemas.android.com/apk/res/android" + + @Serializable + @XmlSerialName("manifest", "", "") + data class AndroidManifest( + @XmlSerialName("package", "", "") + val packageName: String, + + @XmlSerialName("versionCode", ANDROID_NS, "android") + val versionCode: Int? = null, + + @XmlSerialName("versionName", ANDROID_NS, "android") + val versionName: String? = null, + + @XmlElement(true) + @XmlSerialName("uses-sdk", "", "") + val usesSdk: UsesSdk? = null, + + @XmlElement(true) + @XmlSerialName("uses-feature", "", "") + val usesFeatures: List = emptyList(), + + @XmlElement(true) + @XmlSerialName("application", "", "") + val application: Application? = null, + ) + + @Serializable + @XmlSerialName("uses-sdk", "", "") + data class UsesSdk( + @XmlSerialName("minSdkVersion", ANDROID_NS, "android") + val minSdkVersion: Int? = null, + + @XmlSerialName("targetSdkVersion", ANDROID_NS, "android") + val targetSdkVersion: Int? = null, + ) + + @Serializable + @XmlSerialName("uses-feature", "", "") + data class UsesFeature( + @XmlSerialName("name", ANDROID_NS, "android") + val name: String? = null, + ) + + @Serializable + @XmlSerialName("application", "", "") + data class Application( + @XmlSerialName("label", ANDROID_NS, "android") + val label: String? = null, + + @XmlSerialName("icon", ANDROID_NS, "android") + val icon: String? = null, + + @XmlSerialName("allowBackup", ANDROID_NS, "android") + val allowBackup: Boolean? = null, + + @XmlSerialName("extractNativeLibs", ANDROID_NS, "android") + val extractNativeLibs: Boolean? = null, + + @XmlElement(true) + @XmlSerialName("meta-data", "", "") + val metaData: List = emptyList(), + + @XmlElement(true) + @XmlSerialName("activity", "", "") + val activities: List = emptyList(), + ) + + @Serializable + @XmlSerialName("meta-data", "", "") + data class MetaData( + @XmlSerialName("name", ANDROID_NS, "android") + val name: String, + + @XmlSerialName("value", ANDROID_NS, "android") + val value: String? = null, + + @XmlSerialName("resource", ANDROID_NS, "android") + val resource: String? = null, + ) + + @Serializable + @XmlSerialName("activity", "", "") + data class Activity( + @XmlSerialName("name", ANDROID_NS, "android") + val name: String, + + @XmlSerialName("exported", ANDROID_NS, "android") + val exported: Boolean? = null, + + @XmlSerialName("theme", ANDROID_NS, "android") + val theme: String? = null, + + @XmlElement(true) + @XmlSerialName("intent-filter", "", "") + val intentFilters: List = emptyList(), + ) + + @Serializable + @XmlSerialName("intent-filter", "", "") + data class IntentFilter( + @XmlElement(true) + @XmlSerialName("action", "", "") + val actions: List = emptyList(), + + @XmlElement(true) + @XmlSerialName("category", "", "") + val categories: List = emptyList(), + + @XmlElement(true) + @XmlSerialName("data", "", "") + val data: List = emptyList(), + ) + + @Serializable + @XmlSerialName("action", "", "") + data class Action( + @XmlSerialName("name", ANDROID_NS, "android") + val name: String, + ) + + @Serializable + @XmlSerialName("category", "", "") + data class Category( + @XmlSerialName("name", ANDROID_NS, "android") + val name: String, + ) + + @Serializable + @XmlSerialName("data", "", "") + data class Data( + @XmlSerialName("scheme", ANDROID_NS, "android") + val scheme: String? = null, + + @XmlSerialName("host", ANDROID_NS, "android") + val host: String? = null, + + @XmlSerialName("pathPattern", ANDROID_NS, "android") + val pathPattern: String? = null, + ) + + private val xml = XML { + autoPolymorphic = false + repairNamespaces = true + xmlDeclMode = XmlDeclMode.Minimal + defaultPolicy { + unknownChildHandler = UnknownChildHandler { _, _, _, _, _ -> emptyList() } + } + } + + @OptIn(ExperimentalXmlUtilApi::class) + fun parse(input: InputStream): AndroidManifest = + xml.decodeFromReader(AndroidManifest.serializer(), KtXmlReader(input)) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/PackageTools.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/PackageTools.kt index 1671be0a..41a1778c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/PackageTools.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/PackageTools.kt @@ -30,6 +30,8 @@ import java.nio.file.Files import java.nio.file.Path import javax.xml.parsers.DocumentBuilderFactory import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.nameWithoutExtension import kotlin.io.path.relativeTo object PackageTools { @@ -52,15 +54,13 @@ object PackageTools { * Convert dex to jar, a wrapper for the dex2jar library */ fun dex2jar( - dexFile: String, - jarFile: String, - fileNameWithoutType: String, + dexFile: Path, + jarFile: Path, ) { // 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 reader = MultiDexFileReader.open(Files.readAllBytes(dexFile)) val handler = BaksmaliBaseDexExceptionHandler() Dex2jar .from(reader) @@ -73,10 +73,10 @@ object PackageTools { .noCode(false) .skipExceptions(false) .dontSanitizeNames(true) - .to(jarFilePath) + .to(jarFile) if (handler.hasException()) { val rootPath = Path(applicationDirs.extensionsRoot) - val errorFile: Path = rootPath.resolve("$fileNameWithoutType-error.txt") + val errorFile: Path = rootPath.resolve("${dexFile.nameWithoutExtension}-error.txt") logger.error { """ Detail Error Information in File ${errorFile.relativeTo(rootPath)} @@ -89,15 +89,14 @@ object PackageTools { } handler.dump(errorFile, emptyArray()) } else { - BytecodeEditor.fixAndroidClasses(jarFilePath) + BytecodeEditor.fixAndroidClasses(jarFile) } } /** 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) + fun getPackageInfo(apkFile: Path): PackageInfo { + return ApkParsers.getMetaInfo(apkFile.toFile()).toPackageInfo(apkFile.toFile()).apply { + val parsed = ApkFile(apkFile.toFile()) val dbFactory = DocumentBuilderFactory.newInstance() val dBuilder = dbFactory.newDocumentBuilder() val doc = @@ -156,19 +155,19 @@ object PackageTools { * It may return an instance of HttpSource or SourceFactory depending on the extension. */ fun loadExtensionSources( - jarPath: String, + jar: Path, className: String, ): Any { try { - logger.debug { "loading jar with path: $jarPath" } - val classLoader = jarLoaderMap[jarPath] ?: ChildFirstURLClassLoader(arrayOf(Path(jarPath).toUri().toURL())) + logger.debug { "loading jar with path: ${jar.absolutePathString()}" } + val classLoader = jarLoaderMap[jar.absolutePathString()] ?: ChildFirstURLClassLoader(arrayOf(jar.toUri().toURL())) val classToLoad = Class.forName(className, false, classLoader) - jarLoaderMap[jarPath] = classLoader + jarLoaderMap[jar.absolutePathString()] = classLoader return classToLoad.getDeclaredConstructor().newInstance() } catch (e: Exception) { - logger.error(e) { "Failed to load jar with path: $jarPath" } + logger.error(e) { "Failed to load jar with path: ${jar.absolutePathString()}" } throw e } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/ResourceArscIconParser.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/ResourceArscIconParser.kt new file mode 100644 index 00000000..67ec0916 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/ResourceArscIconParser.kt @@ -0,0 +1,94 @@ +package suwayomi.tachidesk.manga.impl.util + +import nl.adaptivity.xmlutil.core.impl.multiplatform.InputStream +import org.apache.commons.compress.archivers.zip.ZipFile +import pxb.android.arsc.ArscParser +import pxb.android.arsc.Config +import pxb.android.arsc.Pkg +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Path +import kotlin.io.path.outputStream + +object ResourceArscIconParser { + private data class IconCandidate( + val density: Int, + val path: String, + ) + + fun extractIcon( + jar: Path, + iconPath: Path + ) { + ZipFile.builder() + .setPath(jar) + .get() + .use { zip -> + val packages = zip.getInputStream(zip.getEntry("resources.arsc")) + .use { ArscParser(it.readBytes()).parse() } + + val icon = packages + .flatMap { it.iconCandidates() } + .maxByOrNull { it.density } + ?: return + + val entry = zip.getEntry(icon.path) ?: return + + zip.getInputStream(entry).use { + iconPath.outputStream().use { out -> + it.copyTo(out) + } + } + } + } + + fun extractIcon( + zip: ZipFile, + ): InputStream { + val packages = zip.getInputStream(zip.getEntry("resources.arsc")) + .use { ArscParser(it.readBytes()).parse() } + + val icon = packages + .flatMap { it.iconCandidates() } + .maxByOrNull { it.density } + ?: throw NullPointerException("No valid icons") + + val entry = zip.getEntry(icon.path) + ?: throw NullPointerException("Icon ${icon.path} missing") + + return zip.getInputStream(entry) + } + + private fun Pkg.iconCandidates(): List = + types.values + .filter { it.name == "mipmap" || it.name == "drawable" } + .flatMap { + it.configs.flatMap { + val density = it.density() + + it.resources.values + .asSequence() + .filter { it.spec.name == "ic_launcher" } + .map { it.value.toString() } + .filter(::isRasterImage) + .map { IconCandidate(density, it) } + } + } + + private fun Config.density(): Int = + ByteBuffer.wrap(id) + .order(ByteOrder.LITTLE_ENDIAN) + .getShort(14) + .toInt() and 0xffff + + private val rasterExtensions = setOf( + "png", + "webp", + "jpg", + "jpeg", + ) + + private fun isRasterImage(path: String): Boolean = + path.substringAfterLast('.', "") + .lowercase() in rasterExtensions +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/source/GetSource.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/source/GetSource.kt index 3909a0ac..52287587 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/source/GetSource.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/source/GetSource.kt @@ -20,6 +20,8 @@ import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.server.ApplicationDirs import uy.kohesive.injekt.injectLazy import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.Path +import kotlin.io.path.div object GetSource { private val logger = KotlinLogging.logger { } @@ -49,7 +51,7 @@ object GetSource { ?: throw NullPointerException("Missing apkName") val className = extensionRecord[ExtensionTable.classFQName] val jarName = apkName.substringBefore(".apk") + ".jar" - val jarPath = "${applicationDirs.extensionsRoot}/$jarName" + val jarPath = Path(applicationDirs.extensionsRoot) / jarName when (val instance = loadExtensionSources(jarPath, className)) { is Source -> listOf(instance) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/ExtensionInfo.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/ExtensionInfo.kt index 22c0b1b1..340eed9e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/ExtensionInfo.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/ExtensionInfo.kt @@ -12,6 +12,7 @@ data class ExtensionInfo( val name: String, val pkgName: String, val apkUrl: String, + val jarUrl: String?, val iconUrl: String, val extensionLib: String, val versionCode: Long, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt index 318c43eb..6c06fa34 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/ExtensionTable.kt @@ -24,6 +24,7 @@ object ExtensionTable : IntIdTable() { val name = varchar("name", 128) val pkgName = varchar("pkg_name", 128) val apkUrl = varchar("apk_url", 2048).nullable() + val jarUrl = varchar("jar_url", 2048).nullable() val extensionLib = varchar("extension_lib", 16).nullable() val versionName = varchar("version_name", 16) val versionCode = long("version_code") diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0059_AddJarUrlToExtensionTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0059_AddJarUrlToExtensionTable.kt new file mode 100644 index 00000000..50328899 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0059_AddJarUrlToExtensionTable.kt @@ -0,0 +1,19 @@ +package suwayomi.tachidesk.server.database.migration + +/* + * 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 de.neonew.exposed.migrations.helpers.AddColumnMigration + +@Suppress("ClassName", "unused") +class M0059_AddJarUrlToExtensionTable : + AddColumnMigration( + "extension", + "jar_url", + "VARCHAR(2048)", + "NULL", + ) diff --git a/server/src/test/kotlin/JarTest.kt b/server/src/test/kotlin/JarTest.kt new file mode 100644 index 00000000..8f640930 --- /dev/null +++ b/server/src/test/kotlin/JarTest.kt @@ -0,0 +1,111 @@ +import org.apache.commons.compress.archivers.zip.ZipFile +import pxb.android.arsc.ArscDumper +import pxb.android.arsc.ArscParser +import pxb.android.axml.AxmlParser +import suwayomi.tachidesk.manga.impl.util.AndroidManifestParser +import suwayomi.tachidesk.manga.impl.util.ResourceArscIconParser +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.test.Test + +class JarTest { + + @Test + fun jarTest() { + println(Path("./").absolutePathString()) + val jar = Path("./build/tachiyomi-all.ahottie-v1.4.3.jar") + + val zip = ZipFile.builder() + .setPath(jar) + .get() + val manifest = zip.getInputStream(zip.getEntry("AndroidManifest.xml")) + .use { + AndroidManifestParser.parse(it) + } + // + // reader.parse() + // println(reader.resourceTable) + // val resourceTable = reader.resourceTable + // println(resourceTable.stringPool) + // //println(table.stringPool.get(1)) + // // table::class.members.forEach { + // // if (it.name == "packageMap") { + // // it.isAccessible = true + // // val map = it.call(table) as Map + // // map.forEach { short, packagz -> + // // println(short) + // // println(packagz.name to packagz.id) + // // } + // // } + // // } + // val apkTranslator = ApkMetaTranslator(resourceTable, null) + // val xmlTranslator = XmlTranslator() + // val xmlStreamer = CompositeXmlStreamer(xmlTranslator, apkTranslator) + // val data = zip.getInputStream(zip.getEntry("AndroidManifest.xml")) + // .use { + // it.readBytes() + // } + // + // + // val buffer = ByteBuffer.wrap(data) + // val binaryXmlParser = BinaryXmlParser(buffer, resourceTable) + // //binaryXmlParser.locale = preferredLocale + // binaryXmlParser.xmlStreamer = xmlStreamer + // binaryXmlParser.parse() + // val manifestXml = xmlTranslator.xml + // println(manifestXml) + // val iconPaths = apkTranslator.iconPaths + // iconPaths.forEach { + // println(it.path) + // } + + // val parser = zip.getInputStream(zip.getEntry("resources.arsc")) + // .use { + // ArscParser(it.readBytes()) + // } + // val parsed = parser.parse() + // ArscDumper.dump(parsed) + // + // parsed.forEach { + // print("pkg.name: ") + // println(it.name) + // it.types.toList().forEachIndexed { t, (i, type) -> + // print("pkg.types.$t.type.name: ") + // println(type.name) + // print("pkg.types.$t.type.id: ") + // println(type.id) + // type.specs.forEachIndexed { y, spec -> + // print("pkg.types.$t.type.specs.$y.id: ") + // println(spec.id) + // print("pkg.types.$t.type.specs.$y.name: ") + // println(spec.name) + // print("pkg.types.$t.type.specs.$y.flags: ") + // println(spec.flags) + // } + // type.configs.forEachIndexed { y, config -> + // print("pkg.types.$t.type.configs.$y.id: ") + // println(config.id.toHexString(HexFormat.UpperCase)) + // print("pkg.types.$t.type.configs.$y.entryCount: ") + // println(config.entryCount) + // config.resources.forEach { + // print("pkg.types.$t.type.configs.$y.resources.${it.key}.flag: ") + // println(it.value.flag) + // print("pkg.types.$t.type.configs.$y.resources.${it.key}.value: ") + // println(it.value.value) + // print("pkg.types.$t.type.configs.$y.resources.${it.key}.spec.id: ") + // println(it.value.spec.id) + // print("pkg.types.$t.type.configs.$y.resources.${it.key}.spec.name: ") + // println(it.value.spec.name) + // print("pkg.types.$t.type.configs.$y.resources.${it.key}.spec.flags: ") + // println(it.value.spec.flags) + // } + // } + // } + // } + + // val fileTest = Path("./build/icon.png") + // ResourceArscIconParser.extractIcon(jar, fileTest) + + println(manifest.packageName) + } +}