From d34a486dca149211bdbf8fe9a10101591c801c65 Mon Sep 17 00:00:00 2001 From: Mitchell Syer Date: Mon, 13 Jul 2026 14:21:08 -0400 Subject: [PATCH] Add ability to install jar versions of extensions (#2182) * Install JARs directly * Changelog * Return pkgName for extension install * Lint * Utility function * Remove test function * Optimize imports * Improvements and fixes * Simplify installExtension * Fix memory leak on APK extension install * Commonize installExtension function * Delete unused function * TextExtensionCompatibility fix * Lint --- CHANGELOG.md | 2 +- gradle/libs.versions.toml | 2 + .../graphql/mutations/ExtensionMutation.kt | 4 +- .../graphql/queries/ExtensionQuery.kt | 4 + .../tachidesk/graphql/types/ExtensionType.kt | 2 + .../manga/controller/ExtensionController.kt | 6 - .../manga/impl/extension/Extension.kt | 643 ++++++++++++------ .../manga/impl/extension/ExtensionsList.kt | 3 + .../extension/github/NetworkExtensionStore.kt | 3 + .../github/NetworkLegacyExtension.kt | 1 + .../manga/impl/util/AndroidManifestParser.kt | 148 ++++ .../tachidesk/manga/impl/util/PackageTools.kt | 108 ++- .../manga/impl/util/ResourceArscIconParser.kt | 71 ++ .../manga/impl/util/source/GetSource.kt | 4 +- .../manga/model/dataclass/ExtensionInfo.kt | 1 + .../manga/model/table/ExtensionTable.kt | 1 + .../M0059_AddJarUrlToExtensionTable.kt | 19 + .../masstest/TestExtensionCompatibility.kt | 28 +- 18 files changed, 753 insertions(+), 297 deletions(-) create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/AndroidManifestParser.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/ResourceArscIconParser.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0059_AddJarUrlToExtensionTable.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a5787806..a6ecef74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] (Preview) ### Added -- . +- (**Extension**) Add ability to install jar versions of extensions ### Changed - . 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/mutations/ExtensionMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt index 4246dd46..b8161931 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/ExtensionMutation.kt @@ -179,10 +179,10 @@ class ExtensionMutation { val (clientMutationId, extensionFile) = input return future { - Extension.installExternalExtension(extensionFile.content(), extensionFile.filename()) + val pkgName = Extension.installExternalExtension(extensionFile.content(), extensionFile.filename()) val dbExtension = - transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq extensionFile.filename() }.first() } + transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() } InstallExternalExtensionPayload( clientMutationId, 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/controller/ExtensionController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/ExtensionController.kt index 220864da..b3cfaa63 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/ExtensionController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/ExtensionController.kt @@ -63,8 +63,6 @@ object ExtensionController { ctx.future { future { Extension.installExtension(pkgName) - }.thenApply { - ctx.status(it) } } }, @@ -99,8 +97,6 @@ object ExtensionController { uploadedFile.content(), uploadedFile.filename(), ) - }.thenApply { - ctx.status(it) } } }, @@ -126,8 +122,6 @@ object ExtensionController { ctx.future { future { Extension.updateExtension(pkgName) - }.thenApply { - ctx.status(it) } } }, 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..be98859d 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 @@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension * 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 android.content.pm.PackageInfo import android.net.Uri import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper @@ -17,9 +18,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging import net.dongliu.apk.parser.ApkFile import net.dongliu.apk.parser.bean.Icon 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 +26,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 +39,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,241 +49,421 @@ 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.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.createParentDirectories +import kotlin.io.path.deleteExisting +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 {} private val applicationDirs: ApplicationDirs by injectLazy() - suspend fun installExtension(pkgName: String): Int { + private suspend fun fetchExtensionFile(url: String): Path { + val name = Uri.parse(url).lastPathSegment!! + val savePath = Path(applicationDirs.tempRoot) / "extensions" / name + // download jar file + downloadExtension(url, savePath) + + return savePath + } + + suspend fun installExtension(pkgName: String): String { 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 installAPK { - val apkName = Uri.parse(apkUrl).lastPathSegment!! - val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName" - // download apk file - downloadAPKFile(apkUrl, apkSavePath) + return when { + jarUrl != null -> { + installExtension { + val jar = fetchExtensionFile(jarUrl) + val manifest = extractAndParseAndroidManifest(jar) + ExtensionPackage.Jar(jar, manifest) + } + } - apkSavePath + apkUrl != null -> { + installExtension { + val apk = fetchExtensionFile(apkUrl) + val packageInfo = getPackageInfo(apk) + ExtensionPackage.Apk(apk, packageInfo) + } + } + + else -> { + throw NullPointerException("Could not find extension url for $pkgName") + } } } + private fun copyToExtensionsRoot( + inputStream: InputStream, + extensionName: String, + ): Path { + val rootPath = Path(applicationDirs.tempRoot) / "extensions" + val downloadedFile = rootPath.resolve(extensionName).normalize() + check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) { + "File '$extensionName' is not a valid extension file" + } + logger.debug { "Saving jar at $extensionName" } + // download jar file + downloadedFile.createParentDirectories() + downloadedFile.outputStream().buffered().use { out -> + inputStream.use { + it.copyTo(out) + } + } + return downloadedFile + } + suspend fun installExternalExtension( inputStream: InputStream, - apkName: String, - ): Int = - installAPK(true) { - val rootPath = Path(applicationDirs.extensionsRoot) - val downloadedFile = rootPath.resolve(apkName).normalize() - check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) { - "File '$apkName' is not a valid extension file" - } - logger.debug { "Saving apk at $apkName" } - // download apk file - downloadedFile.outputStream().sink().buffer().use { sink -> - inputStream.source().use { source -> - sink.writeAll(source) - sink.flush() - } - } - downloadedFile.absolutePathString() - } - - suspend fun installAPK( - forceReinstall: Boolean = false, - fetcher: suspend () -> String, - ): Int { - val apkFilePath = fetcher() - val apkName = File(apkFilePath).name - - // 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() - }?.get(ExtensionTable.isInstalled) ?: false - - val fileNameWithoutType = apkName.substringBefore(".apk") - - val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType" - val jarFilePath = "$dirPathWithoutType.jar" - - val packageInfo = getPackageInfo(apkFilePath) - val pkgName = packageInfo.packageName - if (isInstalled && forceReinstall) { - uninstallExtension(pkgName) - } - - if (!isInstalled || forceReinstall) { - 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", + extensionName: String, + ): String = + when { + extensionName.endsWith(".jar") -> { + installExtension( + true, + { + val jar = copyToExtensionsRoot(inputStream, extensionName) + val manifest = extractAndParseAndroidManifest(jar) + ExtensionPackage.Jar(jar, manifest) + }, ) } - // 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") -// } - - var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING) - if (contentWarning == 0) { - contentWarning = packageInfo.applicationInfo.metaData - .getString(METADATA_CONTENT_WARNING) - ?.toIntOrNull() - ?: 0 - if (contentWarning == 0) { - contentWarning = packageInfo.applicationInfo.metaData - .getString(METADATA_NSFW) - ?.toIntOrNull() - ?: 0 - } + extensionName.endsWith(".apk") -> { + installExtension( + true, + { + val apk = copyToExtensionsRoot(inputStream, extensionName) + val packageInfo = getPackageInfo(apk) + ExtensionPackage.Apk(apk, packageInfo) + }, + ) } - val sourceClass = - packageInfo.applicationInfo.metaData - .getString(METADATA_SOURCE_CLASS)!! - .trim() + else -> { + throw NullPointerException("Could not find extension type for $extensionName") + } + } - val className = - if (sourceClass.startsWith(".")) { - packageInfo.packageName + sourceClass - } else { - sourceClass + sealed class ExtensionPackage { + abstract val file: Path + abstract val metadata: PackageMetadata + + // Abstract hook for type-specific preprocessing + abstract suspend fun prepareJarAndIcons(extensionsRoot: Path): Path + + class Apk( + override val file: Path, + val packageInfo: PackageInfo, + ) : ExtensionPackage() { + override val metadata = + PackageMetadata( + packageName = packageInfo.packageName, + versionName = packageInfo.versionName, + versionCode = packageInfo.versionCode, + reqFeatures = packageInfo.reqFeatures.orEmpty().map { it.name }, + metaData = MetadataProvider.FromPackageInfo(packageInfo.applicationInfo.metaData), + label = packageInfo.applicationInfo.nonLocalizedLabel?.toString(), + ) + + override suspend fun prepareJarAndIcons(extensionsRoot: Path): Path { + val jarFile = extensionsRoot / (file.nameWithoutExtension + ".jar") + dex2jar(file, jarFile) + extractAssetsFromApk(file, jarFile) + extractAndCacheApkIcon(file, metadata.packageName) + file.deleteExisting() + return jarFile + } + } + + class Jar( + override val file: Path, + val manifest: AndroidManifestParser.AndroidManifest, + ) : ExtensionPackage() { + override val metadata = + PackageMetadata( + packageName = manifest.packageName, + versionName = manifest.versionName!!, + versionCode = manifest.versionCode!!, + reqFeatures = manifest.usesFeatures.mapNotNull { it.name }, + metaData = MetadataProvider.FromManifest(manifest.application!!.metaData), + label = manifest.application.label, + ) + + override suspend fun prepareJarAndIcons(extensionsRoot: Path): Path { + val jarFile = extensionsRoot / file.name + + ZipFile.builder().setPath(file).get().use { jarZip -> + try { + cacheIcon( + metadata.packageName, + ResourceArscIconParser.extractIcon(jarZip), + ) + } catch (e: Exception) { + logger.warn(e) { "Failed to extract icon from JAR ${metadata.packageName}" } + } } - logger.debug { "Main class for extension is $className" } + file.copyTo(jarFile) + file.deleteExisting() + return jarFile + } + } + } - dex2jar(apkFilePath, jarFilePath, fileNameWithoutType) - extractAssetsFromApk(apkFilePath, jarFilePath) - extractAndCacheApkIcon(apkFilePath, packageInfo.packageName) + data class PackageMetadata( + val packageName: String, + val versionName: String, + val versionCode: Int, + val reqFeatures: List, + val metaData: MetadataProvider, + val label: String?, + ) - // clean up - File(apkFilePath).delete() + sealed interface MetadataProvider { + fun getString(key: String): String? + + fun getInt(key: String): Int + + class FromPackageInfo( + private val bundle: android.os.Bundle, + ) : MetadataProvider { + override fun getString(key: String): String? = bundle.getString(key) + + override fun getInt(key: String): Int = bundle.getInt(key) ?: 0 + } + + class FromManifest( + private val list: List, + ) : MetadataProvider { + override fun getString(key: String): String? = list.find { it.name == key }?.value + + override fun getInt(key: String): Int = getString(key)?.toIntOrNull() ?: 0 + } + } + + suspend fun installExtension( + forceReinstall: Boolean = false, + fetchPackage: suspend () -> ExtensionPackage, + ): String { + val extPackage = fetchPackage() + val metadata = extPackage.metadata + val pkgName = metadata.packageName + + val isInstalled = + transaction { + ExtensionTable + .select(ExtensionTable.isInstalled) + .where { ExtensionTable.pkgName eq pkgName } + .firstOrNull() + }?.get(ExtensionTable.isInstalled) ?: false + + if (isInstalled) { + if (forceReinstall) { + uninstallExtension(pkgName) + } else { + extPackage.file.deleteExisting() + return pkgName + } + } + + if (!metadata.reqFeatures.contains(EXTENSION_FEATURE)) { + extPackage.file.deleteExisting() + throw Exception("This file is not a Tachiyomi extension") + } + + val libVersion = metadata.versionName.substringBeforeLast('.').toDouble() + if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { + extPackage.file.deleteExisting() + throw Exception( + "Lib version is $libVersion, while only versions " + + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed", + ) + } + + var contentWarning = + extPackage.metadata + .metaData + .getInt(METADATA_CONTENT_WARNING) + if (contentWarning == 0) { + contentWarning = extPackage.metadata + .metaData + .getString(METADATA_CONTENT_WARNING) + ?.toIntOrNull() ?: 0 + if (contentWarning == 0) { + contentWarning = extPackage.metadata + .metaData + .getString(METADATA_NSFW) + ?.toIntOrNull() ?: 0 + } + } + + val sourceClass = + metadata.metaData + .getString(METADATA_SOURCE_CLASS)!! + .trim() + + val className = + if (sourceClass.startsWith(".")) { + pkgName + sourceClass + } else { + sourceClass + } + + logger.debug { "Main class for extension is $className" } + + val extensionsRoot = Path(applicationDirs.extensionsRoot) + val jarFile = extPackage.prepareJarAndIcons(extensionsRoot) + + try { + val extensionName = + metadata.metaData.getString(METADATA_NAME) + ?: metadata.label?.substringAfter("Tachiyomi: ") + ?: throw Exception("Could not resolve extension name") + + val extensionLibVersion = + metadata.metaData + .getString(METADATA_EXTENSION_LIB) + .takeUnless { it == "0" } + ?: metadata.versionName.substringBeforeLast('.') + + val apkName = + when (extPackage) { + is ExtensionPackage.Apk -> extPackage.file.name + is ExtensionPackage.Jar -> jarFile.name.removeSuffix(".jar") + ".apk" + } + + setupJar( + jarFile = jarFile, + className = className, + extensionName = extensionName, + extensionLibVersion = extensionLibVersion, + apkName = apkName, + pkgName = pkgName, + versionName = metadata.versionName, + versionCode = metadata.versionCode, + contentWarning = contentWarning, + ) + } catch (e: Throwable) { + // free up the file descriptor if exists + PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close() + jarFile.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 - .toString() - .substringAfter("Tachiyomi: ") - - val extensionLibVersion = - packageInfo.applicationInfo.metaData - .getString(METADATA_EXTENSION_LIB) - .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 - } - } - - 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() - - try { - uninstallExtension(pkgName) - } catch (_: Throwable) { - } - throw e + uninstallExtension(pkgName) + } catch (_: Throwable) { + } + throw e + } + return pkgName + } + + 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}" } } - } else { - return 302 // extension was already installed } } 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,36 @@ 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 cacheIcon( + pkgName: String, + inputStream: InputStream, ) { - val apkFile = File(apkPath) - val jarFile = File(jarPath) + val iconCacheDir = Path(applicationDirs.extensionsRoot) / "icon" + iconCacheDir.createDirectories() + clearCachedImage(iconCacheDir.absolutePathString(), pkgName) + saveImage("$iconCacheDir/$pkgName", inputStream, null) + } - val assetsFolder = File("${apkFile.parent}/${apkFile.nameWithoutExtension}_assets") - assetsFolder.mkdir() + @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.createParentDirectories() + assetFile.outputStream().use { outputStream -> zipInputStream.copyTo(outputStream) } } @@ -325,9 +511,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 +522,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,17 +534,25 @@ object Extension { } } - jarFile.delete() - tempJarFile.renameTo(jarFile) + jarFile.deleteIfExists() + tempJarFile.copyTo(jarFile) + tempJarFile.deleteExisting() assetsFolder.deleteRecursively() } + private fun extractAndParseAndroidManifest(jar: Path): AndroidManifestParser.AndroidManifest = + ZipFile.builder().setPath(jar).get().use { jarZip -> + jarZip.getInputStream(jarZip.getEntry("AndroidManifest.xml")).use { + AndroidManifestParser.parse(it) + } + } + private val network: NetworkHelper by injectLazy() - private suspend fun downloadAPKFile( + private suspend fun downloadExtension( url: String, - savePath: String, + savePath: Path, ) { val response = network.client @@ -366,11 +560,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() + savePath.createParentDirectories() + response.body.byteStream().use { + savePath.outputStream().buffered().use { out -> + it.copyTo(out) } } } @@ -382,7 +575,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,18 +597,18 @@ 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.deleteExisting() } } - suspend fun updateExtension(pkgName: String): Int { + suspend fun updateExtension(pkgName: String): String { val targetExtension = ExtensionsList.updateMap.remove(pkgName)!! uninstallExtension(pkgName) transaction { 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..ad4bb6e9 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/AndroidManifestParser.kt @@ -0,0 +1,148 @@ +package suwayomi.tachidesk.manga.impl.util + +import kotlinx.serialization.Serializable +import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi +import nl.adaptivity.xmlutil.XmlDeclMode +import nl.adaptivity.xmlutil.core.KtXmlReader +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 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, + ) + + @OptIn(ExperimentalXmlUtilApi::class) + 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..7b57ab8b 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 @@ -23,13 +23,14 @@ import suwayomi.tachidesk.server.ApplicationDirs import uy.kohesive.injekt.injectLazy 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 import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.nameWithoutExtension +import kotlin.io.path.readBytes import kotlin.io.path.relativeTo object PackageTools { @@ -52,15 +53,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(dexFile.readBytes()) val handler = BaksmaliBaseDexExceptionHandler() Dex2jar .from(reader) @@ -73,10 +72,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,56 +88,55 @@ 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) - val dbFactory = DocumentBuilderFactory.newInstance() - val dBuilder = dbFactory.newDocumentBuilder() - val doc = - parsed.manifestXml.byteInputStream().use { - dBuilder.parse(it) - } + fun getPackageInfo(apkFile: Path): PackageInfo = + ApkParsers.getMetaInfo(apkFile.toFile()).toPackageInfo(apkFile.toFile()).apply { + ApkFile(apkFile.toFile()).use { parsed -> + val dbFactory = DocumentBuilderFactory.newInstance() + val dBuilder = dbFactory.newDocumentBuilder() + val doc = + parsed.manifestXml.byteInputStream().use { + dBuilder.parse(it) + } - logger.trace { parsed.manifestXml } + logger.trace { parsed.manifestXml } - applicationInfo.metaData = - Bundle().apply { - val appTag = doc.getElementsByTagName("application").item(0) + applicationInfo.metaData = + Bundle().apply { + val appTag = doc.getElementsByTagName("application").item(0) - appTag - ?.childNodes - ?.toList() - .orEmpty() - .asSequence() - .filter { - it.nodeType == Node.ELEMENT_NODE - }.map { - it as Element - }.filter { - it.tagName == "meta-data" - }.forEach { - putString( - it.attributes.getNamedItem("android:name").nodeValue, - it.attributes.getNamedItem("android:value").nodeValue, - ) - } - } + appTag + ?.childNodes + ?.toList() + .orEmpty() + .asSequence() + .filter { + it.nodeType == Node.ELEMENT_NODE + }.map { + it as Element + }.filter { + it.tagName == "meta-data" + }.forEach { + 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() + 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 @@ -156,19 +154,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..5afb38ca --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/util/ResourceArscIconParser.kt @@ -0,0 +1,71 @@ +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 + +object ResourceArscIconParser { + private data class IconCandidate( + val density: Int, + val path: String, + ) + + 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/masstest/TestExtensionCompatibility.kt b/server/src/test/kotlin/masstest/TestExtensionCompatibility.kt index f4af9e89..8193bc23 100644 --- a/server/src/test/kotlin/masstest/TestExtensionCompatibility.kt +++ b/server/src/test/kotlin/masstest/TestExtensionCompatibility.kt @@ -27,6 +27,7 @@ import suwayomi.tachidesk.manga.impl.Source.getSourceList import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension import suwayomi.tachidesk.manga.impl.extension.Extension.updateExtension +import suwayomi.tachidesk.manga.impl.extension.ExtensionStoreService import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.getExtensionList import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrNull import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass @@ -50,6 +51,8 @@ class TestExtensionCompatibility { private val chaptersToFetch = mutableListOf>() private val chaptersPageListFailedToFetch = mutableListOf, Exception>>() + val repos = listOf() + @BeforeAll fun setup() { val dataRoot = File(BASE_PATH).absolutePath @@ -57,6 +60,14 @@ class TestExtensionCompatibility { Looper.clearMainLooperForTest() SettingsRegistry.clear() applicationSetup() + repos.forEach { + val store = + runBlocking { + ExtensionStoreService.fetch(it) + } + ExtensionStoreService.upsert(store) + } + ExtensionStoreService.syncDbToPrefs() setLoggingEnabled(false) runBlocking { @@ -72,7 +83,10 @@ class TestExtensionCompatibility { } else -> { - uninstallExtension(it.pkgName) + try { + uninstallExtension(it.pkgName) + } catch (_: Exception) { + } installExtension(it.pkgName) } } @@ -135,9 +149,9 @@ class TestExtensionCompatibility { repeat { source.getMangaUpdate(manga, emptyList(), true, true) } } catch (e: Exception) { logger.warn { - "Failed to fetch manga info and chapters from $source for ${manga.title} (${source.mangaDetailsRequest( + "Failed to fetch manga info and chapters from $source for ${manga.title} (${source.getMangaUrl( manga, - ).url}): ${e.message}" + )}): ${e.message}" } mangaFailedToFetch += Triple(source, manga, e) } @@ -147,7 +161,7 @@ class TestExtensionCompatibility { File("$BASE_PATH/MangaFailedToFetch.txt").writeText( mangaFailedToFetch.joinToString("\n") { (source, manga, exception) -> "${source.name} (${source.lang}, ${source.id}):" + - " ${manga.title} (${source.mangaDetailsRequest(manga).url}):" + + " ${manga.title} (${source.getMangaUrl(manga)}):" + " ${exception.message}" }, ) @@ -163,9 +177,9 @@ class TestExtensionCompatibility { repeat { source.getPageList(chapter) } } catch (e: Exception) { logger.warn { - "Failed to fetch manga info from $source for ${manga.title} (${source.mangaDetailsRequest( + "Failed to fetch manga info from $source for ${manga.title} (${source.getMangaUrl( manga, - ).url}): ${e.message}" + )}): ${e.message}" } chaptersPageListFailedToFetch += Triple(source, manga to chapter, e) } @@ -176,7 +190,7 @@ class TestExtensionCompatibility { File("$BASE_PATH/ChapterPageListFailedToFetch.txt").writeText( chaptersPageListFailedToFetch.joinToString("\n") { (source, manga, exception) -> "${source.name} (${source.lang}, ${source.id}):" + - " ${manga.first.title} (${source.mangaDetailsRequest(manga.first).url}):" + + " ${manga.first.title} (${source.getMangaUrl(manga.first)}):" + " ${manga.second.name} (${manga.second.url}): ${exception.message}" }, )