Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
027356eda5 Update plugin shadowjar to v9 2026-07-06 11:57:10 +00:00
20 changed files with 284 additions and 754 deletions

View File

@@ -42,7 +42,7 @@ body:
label: Suwayomi-Server version label: Suwayomi-Server version
description: You can find your Suwayomi-Server version in **More → About**. description: You can find your Suwayomi-Server version in **More → About**.
placeholder: | placeholder: |
Example: "v2.3.2243" Example: "v2.3.2238"
validations: validations:
required: true required: true

View File

@@ -15,14 +15,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Fixed ### Fixed
- . - .
## [v2.3.2243] - 2026-07-13
### Added
- (**Extension**) Add ability to install jar versions of extensions
### Fixed
- (**Extension**) Fix missing memo field in getPageList
## [v2.3.2238] - 2026-07-05 ## [v2.3.2238] - 2026-07-05
### Changed ### Changed
@@ -508,8 +500,7 @@ Huge thanks to @martinek who pulled the most of the weight this release!
<!-- SERVER LINKS --> <!-- SERVER LINKS -->
[unreleased]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2243...HEAD [unreleased]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2238...HEAD
[v2.3.2243]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2238...v2.3.2243
[v2.3.2238]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2232...v2.3.2238 [v2.3.2238]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2232...v2.3.2238
[v2.3.2232]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2230...v2.3.2232 [v2.3.2232]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2230...v2.3.2232
[v2.3.2230]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2223...v2.3.2230 [v2.3.2230]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2223...v2.3.2230

View File

@@ -110,7 +110,6 @@ dex2jar-tools = { module = "de.femtopedia.dex2jar:dex-tools", version.ref = "dex
# APK # APK
apk-parser = "net.dongliu:apk-parser:2.6.10" apk-parser = "net.dongliu:apk-parser:2.6.10"
apksig = "com.android.tools.build:apksig:9.2.1" apksig = "com.android.tools.build:apksig:9.2.1"
axml = "com.github.Aliucord:axml:ed26565eb0"
# Xml # Xml
xmlpull = "xmlpull:xmlpull:1.1.3.4a" xmlpull = "xmlpull:xmlpull:1.1.3.4a"
@@ -187,7 +186,7 @@ buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.10"}
download = { id = "de.undercouch.download", version = "5.7.0"} download = { id = "de.undercouch.download", version = "5.7.0"}
# ShadowJar # ShadowJar
shadowjar = { id = "com.gradleup.shadow", version = "8.3.11"} shadowjar = { id = "com.gradleup.shadow", version = "9.5.1"}
# Moko # Moko
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" } moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
@@ -216,7 +215,6 @@ shared = [
"dex2jar-translator", "dex2jar-translator",
"dex2jar-tools", "dex2jar-tools",
"apk-parser", "apk-parser",
"axml",
"jackson-annotations", "jackson-annotations",
"jcef", "jcef",
] ]

View File

@@ -179,10 +179,10 @@ class ExtensionMutation {
val (clientMutationId, extensionFile) = input val (clientMutationId, extensionFile) = input
return future { return future {
val pkgName = Extension.installExternalExtension(extensionFile.content(), extensionFile.filename()) Extension.installExternalExtension(extensionFile.content(), extensionFile.filename())
val dbExtension = val dbExtension =
transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() } transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq extensionFile.filename() }.first() }
InstallExternalExtensionPayload( InstallExternalExtensionPayload(
clientMutationId, clientMutationId,

View File

@@ -103,7 +103,6 @@ class ExtensionQuery {
val name: String? = null, val name: String? = null,
val pkgName: String? = null, val pkgName: String? = null,
val apkUrl: String? = null, val apkUrl: String? = null,
val jarUrl: String? = null,
val extensionLib: String? = null, val extensionLib: String? = null,
val versionName: String? = null, val versionName: String? = null,
val versionCode: Int? = null, val versionCode: Int? = null,
@@ -123,7 +122,6 @@ class ExtensionQuery {
opAnd.eq(apkName, ExtensionTable.apkName) opAnd.eq(apkName, ExtensionTable.apkName)
opAnd.eq(iconUrl, ExtensionTable.iconUrl) opAnd.eq(iconUrl, ExtensionTable.iconUrl)
opAnd.eq(apkUrl, ExtensionTable.apkUrl) opAnd.eq(apkUrl, ExtensionTable.apkUrl)
opAnd.eq(jarUrl, ExtensionTable.jarUrl)
opAnd.eq(name, ExtensionTable.name) opAnd.eq(name, ExtensionTable.name)
opAnd.eq(extensionLib, ExtensionTable.extensionLib) opAnd.eq(extensionLib, ExtensionTable.extensionLib)
opAnd.eq(versionName, ExtensionTable.versionName) opAnd.eq(versionName, ExtensionTable.versionName)
@@ -152,7 +150,6 @@ class ExtensionQuery {
val name: StringFilter? = null, val name: StringFilter? = null,
val pkgName: StringFilter? = null, val pkgName: StringFilter? = null,
val apkUrl: StringFilter? = null, val apkUrl: StringFilter? = null,
val jarUrl: StringFilter? = null,
val versionName: StringFilter? = null, val versionName: StringFilter? = null,
val extensionLib: StringFilter? = null, val extensionLib: StringFilter? = null,
@GraphQLDeprecated("", ReplaceWith("versionCodeLong")) @GraphQLDeprecated("", ReplaceWith("versionCodeLong"))
@@ -178,7 +175,6 @@ class ExtensionQuery {
andFilterWithCompareString(ExtensionTable.name, name), andFilterWithCompareString(ExtensionTable.name, name),
andFilterWithCompareString(ExtensionTable.pkgName, pkgName), andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl), andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl),
andFilterWithCompareString(ExtensionTable.jarUrl, jarUrl),
andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib), andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib),
andFilterWithCompareString(ExtensionTable.versionName, versionName), andFilterWithCompareString(ExtensionTable.versionName, versionName),
andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong), andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),

View File

@@ -32,7 +32,6 @@ class ExtensionType(
val name: String, val name: String,
val pkgName: String, val pkgName: String,
val apkUrl: String?, val apkUrl: String?,
val jarUrl: String?,
val extensionLib: String?, val extensionLib: String?,
val versionName: String, val versionName: String,
@GraphQLDeprecated( @GraphQLDeprecated(
@@ -57,7 +56,6 @@ class ExtensionType(
name = row[ExtensionTable.name], name = row[ExtensionTable.name],
pkgName = row[ExtensionTable.pkgName], pkgName = row[ExtensionTable.pkgName],
apkUrl = row[ExtensionTable.apkUrl], apkUrl = row[ExtensionTable.apkUrl],
jarUrl = row[ExtensionTable.jarUrl],
extensionLib = row[ExtensionTable.extensionLib], extensionLib = row[ExtensionTable.extensionLib],
versionName = row[ExtensionTable.versionName], versionName = row[ExtensionTable.versionName],
versionCode = row[ExtensionTable.versionCode].toInt(), versionCode = row[ExtensionTable.versionCode].toInt(),

View File

@@ -63,6 +63,8 @@ object ExtensionController {
ctx.future { ctx.future {
future { future {
Extension.installExtension(pkgName) Extension.installExtension(pkgName)
}.thenApply {
ctx.status(it)
} }
} }
}, },
@@ -97,6 +99,8 @@ object ExtensionController {
uploadedFile.content(), uploadedFile.content(),
uploadedFile.filename(), uploadedFile.filename(),
) )
}.thenApply {
ctx.status(it)
} }
} }
}, },
@@ -122,6 +126,8 @@ object ExtensionController {
ctx.future { ctx.future {
future { future {
Extension.updateExtension(pkgName) Extension.updateExtension(pkgName)
}.thenApply {
ctx.status(it)
} }
} }
}, },

View File

@@ -14,7 +14,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.reactivecircus.cache4k.Cache import io.github.reactivecircus.cache4k.Cache
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
@@ -89,7 +88,6 @@ suspend fun refreshChapterPageList(
scanlator = chapterEntry[ChapterTable.scanlator] scanlator = chapterEntry[ChapterTable.scanlator]
chapter_number = chapterEntry[ChapterTable.chapter_number] chapter_number = chapterEntry[ChapterTable.chapter_number]
date_upload = chapterEntry[ChapterTable.date_upload] date_upload = chapterEntry[ChapterTable.date_upload]
memo = Json.decodeFromString(chapterEntry[ChapterTable.memo])
}, },
).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) } ).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }

View File

@@ -7,7 +7,6 @@ package suwayomi.tachidesk.manga.impl.extension
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import android.content.pm.PackageInfo
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
@@ -18,7 +17,9 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import net.dongliu.apk.parser.ApkFile import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.bean.Icon import net.dongliu.apk.parser.bean.Icon
import okhttp3.CacheControl import okhttp3.CacheControl
import org.apache.commons.compress.archivers.zip.ZipFile import okio.buffer
import okio.sink
import okio.source
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insert
@@ -26,7 +27,6 @@ import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update 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
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
@@ -39,7 +39,6 @@ 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.dex2jar
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources 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.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetSource import suwayomi.tachidesk.manga.impl.util.source.GetSource
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
@@ -49,421 +48,238 @@ import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.nio.file.Path
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.Path import kotlin.io.path.Path
import kotlin.io.path.absolutePathString 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.outputStream
import kotlin.io.path.relativeTo
import kotlin.io.path.walk
object Extension { object Extension {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val applicationDirs: ApplicationDirs by injectLazy() private val applicationDirs: ApplicationDirs by injectLazy()
private suspend fun fetchExtensionFile(url: String): Path { suspend fun installExtension(pkgName: String): Int {
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" } logger.debug { "Installing $pkgName" }
val extension = val apkUrl =
transaction { transaction {
ExtensionTable ExtensionTable
.select(ExtensionTable.apkUrl, ExtensionTable.jarUrl) .select(ExtensionTable.apkUrl)
.where { ExtensionTable.pkgName eq pkgName } .where { ExtensionTable.pkgName eq pkgName }
.firstOrNull() .firstOrNull()
} ?: throw NullPointerException("Could not find extension for $pkgName") ?.get(ExtensionTable.apkUrl)
val jarUrl = extension[ExtensionTable.jarUrl] } ?: throw NullPointerException("Could not find extension $pkgName")
val apkUrl = extension[ExtensionTable.apkUrl]
return when { return installAPK {
jarUrl != null -> { val apkName = Uri.parse(apkUrl).lastPathSegment!!
installExtension { val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
val jar = fetchExtensionFile(jarUrl) // download apk file
val manifest = extractAndParseAndroidManifest(jar) downloadAPKFile(apkUrl, apkSavePath)
ExtensionPackage.Jar(jar, manifest)
}
}
apkUrl != null -> { apkSavePath
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( suspend fun installExternalExtension(
inputStream: InputStream, inputStream: InputStream,
extensionName: String, apkName: String,
): String = ): Int =
when { installAPK(true) {
extensionName.endsWith(".jar") -> { val rootPath = Path(applicationDirs.extensionsRoot)
installExtension( val downloadedFile = rootPath.resolve(apkName).normalize()
true, check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
{ "File '$apkName' is not a valid extension file"
val jar = copyToExtensionsRoot(inputStream, extensionName)
val manifest = extractAndParseAndroidManifest(jar)
ExtensionPackage.Jar(jar, manifest)
},
)
} }
logger.debug { "Saving apk at $apkName" }
extensionName.endsWith(".apk") -> { // download apk file
installExtension( downloadedFile.outputStream().sink().buffer().use { sink ->
true, inputStream.source().use { source ->
{ sink.writeAll(source)
val apk = copyToExtensionsRoot(inputStream, extensionName) sink.flush()
val packageInfo = getPackageInfo(apk)
ExtensionPackage.Apk(apk, packageInfo)
},
)
}
else -> {
throw NullPointerException("Could not find extension type for $extensionName")
}
}
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}" }
}
} }
file.copyTo(jarFile)
file.deleteExisting()
return jarFile
} }
} downloadedFile.absolutePathString()
}
data class PackageMetadata(
val packageName: String,
val versionName: String,
val versionCode: Int,
val reqFeatures: List<String>,
val metaData: MetadataProvider,
val label: String?,
)
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( suspend fun installAPK(
private val list: List<AndroidManifestParser.MetaData>,
) : 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, forceReinstall: Boolean = false,
fetchPackage: suspend () -> ExtensionPackage, fetcher: suspend () -> String,
): String { ): Int {
val extPackage = fetchPackage() val apkFilePath = fetcher()
val metadata = extPackage.metadata val apkName = File(apkFilePath).name
val pkgName = metadata.packageName
// 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 = val isInstalled =
transaction { transaction {
ExtensionTable ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.firstOrNull()
.select(ExtensionTable.isInstalled)
.where { ExtensionTable.pkgName eq pkgName }
.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false }?.get(ExtensionTable.isInstalled) ?: false
if (isInstalled) { val fileNameWithoutType = apkName.substringBefore(".apk")
if (forceReinstall) {
uninstallExtension(pkgName) val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
} else { val jarFilePath = "$dirPathWithoutType.jar"
extPackage.file.deleteExisting()
return pkgName 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")
} }
}
if (!metadata.reqFeatures.contains(EXTENSION_FEATURE)) { // Validate lib version
extPackage.file.deleteExisting() val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
throw Exception("This file is not a Tachiyomi extension") if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
} throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
)
}
val libVersion = metadata.versionName.substringBeforeLast('.').toDouble() // TODO: allow trusting keys
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { // val signatureHash = getSignatureHash(packageInfo)
extPackage.file.deleteExisting()
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
)
}
var contentWarning = // if (signatureHash == null) {
extPackage.metadata // throw Exception("Package $pkgName isn't signed")
.metaData // } else if (signatureHash !in trustedSignatures) {
.getInt(METADATA_CONTENT_WARNING) // throw Exception("This apk is not a signed with the official tachiyomi signature")
if (contentWarning == 0) { // }
contentWarning = extPackage.metadata
.metaData var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING)
.getString(METADATA_CONTENT_WARNING)
?.toIntOrNull() ?: 0
if (contentWarning == 0) { if (contentWarning == 0) {
contentWarning = extPackage.metadata contentWarning = packageInfo.applicationInfo.metaData
.metaData .getString(METADATA_CONTENT_WARNING)
.getString(METADATA_NSFW) ?.toIntOrNull()
?.toIntOrNull() ?: 0 ?: 0
} if (contentWarning == 0) {
} contentWarning = packageInfo.applicationInfo.metaData
.getString(METADATA_NSFW)
val sourceClass = ?.toIntOrNull()
metadata.metaData ?: 0
.getString(METADATA_SOURCE_CLASS)!! }
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
} }
logger.debug { "Main class for extension is $className" } val sourceClass =
packageInfo.applicationInfo.metaData
.getString(METADATA_SOURCE_CLASS)!!
.trim()
val extensionsRoot = Path(applicationDirs.extensionsRoot) val className =
val jarFile = extPackage.prepareJarAndIcons(extensionsRoot) if (sourceClass.startsWith(".")) {
packageInfo.packageName + sourceClass
try { } else {
val extensionName = sourceClass
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( logger.debug { "Main class for extension is $className" }
jarFile = jarFile,
className = className, dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
extensionName = extensionName, extractAssetsFromApk(apkFilePath, jarFilePath)
extensionLibVersion = extensionLibVersion, extractAndCacheApkIcon(apkFilePath, packageInfo.packageName)
apkName = apkName,
pkgName = pkgName, // clean up
versionName = metadata.versionName, File(apkFilePath).delete()
versionCode = metadata.versionCode,
contentWarning = contentWarning,
)
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
jarFile.deleteIfExists()
try { try {
// collect sources from the extension
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className)
val sources: List<Source> =
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()
uninstallExtension(pkgName) uninstallExtension(pkgName)
} catch (_: Throwable) { throw e
}
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<Source> =
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( private fun extractAndCacheApkIcon(
apkFile: Path, apkFilePath: String,
pkgName: String, pkgName: String,
) { ) {
val iconCacheDir = "${applicationDirs.extensionsRoot}/icon"
try { try {
val iconData = val iconData =
ApkFile(apkFile.toFile()).use { apk -> ApkFile(File(apkFilePath)).use { apk ->
apk.allIcons apk.allIcons
.filterIsInstance<Icon>() .filterIsInstance<Icon>()
.mapNotNull { it.data?.let { data -> data to it.density } } .mapNotNull { it.data?.let { data -> data to it.density } }
@@ -474,36 +290,31 @@ object Extension {
logger.warn { "No icon found in APK $pkgName" } logger.warn { "No icon found in APK $pkgName" }
return return
} }
cacheIcon(pkgName, iconData.inputStream())
File(iconCacheDir).mkdirs()
clearCachedImage(iconCacheDir, pkgName)
saveImage("$iconCacheDir/$pkgName", iconData.inputStream(), null)
} catch (e: Exception) { } catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from APK $pkgName" } logger.warn(e) { "Failed to extract icon from APK $pkgName" }
} }
} }
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( private fun extractAssetsFromApk(
apkFile: Path, apkPath: String,
jarFile: Path, jarPath: String,
) { ) {
val assetsFolder = apkFile.parent / "${apkFile.nameWithoutExtension}_assets" val apkFile = File(apkPath)
assetsFolder.createDirectories() val jarFile = File(jarPath)
val assetsFolder = File("${apkFile.parent}/${apkFile.nameWithoutExtension}_assets")
assetsFolder.mkdir()
ZipInputStream(apkFile.inputStream()).use { zipInputStream -> ZipInputStream(apkFile.inputStream()).use { zipInputStream ->
var zipEntry = zipInputStream.nextEntry var zipEntry = zipInputStream.nextEntry
while (zipEntry != null) { while (zipEntry != null) {
if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) { if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) {
val assetFile = assetsFolder / zipEntry.name val assetFile = File(assetsFolder, zipEntry.name)
assetFile.createParentDirectories() assetFile.parentFile.mkdirs()
assetFile.outputStream().use { outputStream -> FileOutputStream(assetFile).use { outputStream ->
zipInputStream.copyTo(outputStream) zipInputStream.copyTo(outputStream)
} }
} }
@@ -511,9 +322,9 @@ object Extension {
} }
} }
val tempJarFile = jarFile.parent / "${jarFile.nameWithoutExtension}_temp.jar" val tempJarFile = File("${jarFile.parent}/${jarFile.nameWithoutExtension}_temp.jar")
ZipInputStream(jarFile.inputStream()).use { jarZipInputStream -> ZipInputStream(jarFile.inputStream()).use { jarZipInputStream ->
ZipOutputStream(tempJarFile.outputStream()).use { jarZipOutputStream -> ZipOutputStream(FileOutputStream(tempJarFile)).use { jarZipOutputStream ->
var zipEntry = jarZipInputStream.nextEntry var zipEntry = jarZipInputStream.nextEntry
while (zipEntry != null) { while (zipEntry != null) {
if (!zipEntry.name.startsWith("META-INF/")) { if (!zipEntry.name.startsWith("META-INF/")) {
@@ -522,8 +333,8 @@ object Extension {
} }
zipEntry = jarZipInputStream.nextEntry zipEntry = jarZipInputStream.nextEntry
} }
assetsFolder.walk().forEach { file -> assetsFolder.walkTopDown().forEach { file ->
if (file.isRegularFile()) { if (file.isFile) {
jarZipOutputStream.putNextEntry(ZipEntry(file.relativeTo(assetsFolder).toString().replace("\\", "/"))) jarZipOutputStream.putNextEntry(ZipEntry(file.relativeTo(assetsFolder).toString().replace("\\", "/")))
file.inputStream().use { inputStream -> file.inputStream().use { inputStream ->
inputStream.copyTo(jarZipOutputStream) inputStream.copyTo(jarZipOutputStream)
@@ -534,25 +345,17 @@ object Extension {
} }
} }
jarFile.deleteIfExists() jarFile.delete()
tempJarFile.copyTo(jarFile) tempJarFile.renameTo(jarFile)
tempJarFile.deleteExisting()
assetsFolder.deleteRecursively() 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 val network: NetworkHelper by injectLazy()
private suspend fun downloadExtension( private suspend fun downloadAPKFile(
url: String, url: String,
savePath: Path, savePath: String,
) { ) {
val response = val response =
network.client network.client
@@ -560,10 +363,11 @@ object Extension {
GET(url, cache = CacheControl.FORCE_NETWORK), GET(url, cache = CacheControl.FORCE_NETWORK),
).await() ).await()
savePath.createParentDirectories() val downloadedFile = File(savePath)
response.body.byteStream().use { downloadedFile.sink().buffer().use { sink ->
savePath.outputStream().buffered().use { out -> response.body.source().use { source ->
it.copyTo(out) sink.writeAll(source)
sink.flush()
} }
} }
} }
@@ -575,7 +379,7 @@ object Extension {
val fileNameWithoutType = val fileNameWithoutType =
extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk") extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk")
?: throw NullPointerException("Missing $pkgName apkName") ?: throw NullPointerException("Missing $pkgName apkName")
val jarPath = Path(applicationDirs.extensionsRoot) / "$fileNameWithoutType.jar" val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
val sources = val sources =
transaction { transaction {
val extensionId = extensionRecord[ExtensionTable.id].value val extensionId = extensionRecord[ExtensionTable.id].value
@@ -597,18 +401,18 @@ object Extension {
sources sources
} }
if (jarPath.exists()) { if (File(jarPath).exists()) {
// free up the file descriptor if exists // free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarPath.absolutePathString())?.close() PackageTools.jarLoaderMap.remove(jarPath)?.close()
// clear all loaded sources // clear all loaded sources
sources.forEach { GetSource.unregisterSource(it) } sources.forEach { GetSource.unregisterSource(it) }
jarPath.deleteExisting() File(jarPath).delete()
} }
} }
suspend fun updateExtension(pkgName: String): String { suspend fun updateExtension(pkgName: String): Int {
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!! val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
uninstallExtension(pkgName) uninstallExtension(pkgName)
transaction { transaction {

View File

@@ -133,7 +133,6 @@ object ExtensionsList {
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
this[ExtensionTable.apkUrl] = foundExtension.apkUrl this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
// add these because batch updates need matching columns // add these because batch updates need matching columns
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate] this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
@@ -177,7 +176,6 @@ object ExtensionsList {
this[ExtensionTable.lang] = foundExtension.lang this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
} }
}.toExecutable() }.toExecutable()
@@ -195,7 +193,6 @@ object ExtensionsList {
this[ExtensionTable.lang] = foundExtension.lang this[ExtensionTable.lang] = foundExtension.lang
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
this[ExtensionTable.apkUrl] = foundExtension.apkUrl this[ExtensionTable.apkUrl] = foundExtension.apkUrl
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
this[ExtensionTable.iconUrl] = foundExtension.iconUrl this[ExtensionTable.iconUrl] = foundExtension.iconUrl
} }
} }

View File

@@ -53,8 +53,6 @@ data class NetworkExtensionStore(
data class Resources( data class Resources(
@ProtoNumber(1) val apkUrl: String, @ProtoNumber(1) val apkUrl: String,
@ProtoNumber(2) val iconUrl: String, @ProtoNumber(2) val iconUrl: String,
// Keiyoushi specific output
@ProtoNumber(501) val jarUrl: String? = null,
) )
@Serializable @Serializable
@@ -111,7 +109,6 @@ fun NetworkExtensionStore.ExtensionList.toExtensionInfos(store: ExtensionStore):
name = extension.name, name = extension.name,
pkgName = extension.packageName, pkgName = extension.packageName,
apkUrl = extension.resources.apkUrl, apkUrl = extension.resources.apkUrl,
jarUrl = extension.resources.jarUrl,
iconUrl = extension.resources.iconUrl, iconUrl = extension.resources.iconUrl,
extensionLib = extension.extensionLib, extensionLib = extension.extensionLib,
versionCode = extension.versionCode, versionCode = extension.versionCode,

View File

@@ -44,7 +44,6 @@ fun NetworkLegacyExtension.toExtensionInfo(
name = name.substringAfter("Tachiyomi: "), name = name.substringAfter("Tachiyomi: "),
pkgName = pkg, pkgName = pkg,
apkUrl = "$storeBaseUrl/apk/$apk", apkUrl = "$storeBaseUrl/apk/$apk",
jarUrl = null,
iconUrl = "$storeBaseUrl/icon/$pkg.png", iconUrl = "$storeBaseUrl/icon/$pkg.png",
extensionLib = version.substringBeforeLast('.'), extensionLib = version.substringBeforeLast('.'),
versionCode = code, versionCode = code,

View File

@@ -1,148 +0,0 @@
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<UsesFeature> = 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<MetaData> = emptyList(),
@XmlElement(true)
@XmlSerialName("activity", "", "")
val activities: List<Activity> = 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<IntentFilter> = emptyList(),
)
@Serializable
@XmlSerialName("intent-filter", "", "")
data class IntentFilter(
@XmlElement(true)
@XmlSerialName("action", "", "")
val actions: List<Action> = emptyList(),
@XmlElement(true)
@XmlSerialName("category", "", "")
val categories: List<Category> = emptyList(),
@XmlElement(true)
@XmlSerialName("data", "", "")
val data: List<Data> = 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))
}

View File

@@ -23,14 +23,13 @@ import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
import xyz.nulldev.androidcompat.pm.toPackageInfo import xyz.nulldev.androidcompat.pm.toPackageInfo
import java.io.File
import java.net.URL import java.net.URL
import java.net.URLClassLoader import java.net.URLClassLoader
import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.DocumentBuilderFactory
import kotlin.io.path.Path 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 import kotlin.io.path.relativeTo
object PackageTools { object PackageTools {
@@ -53,13 +52,15 @@ object PackageTools {
* Convert dex to jar, a wrapper for the dex2jar library * Convert dex to jar, a wrapper for the dex2jar library
*/ */
fun dex2jar( fun dex2jar(
dexFile: Path, dexFile: String,
jarFile: Path, jarFile: String,
fileNameWithoutType: String,
) { ) {
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine // 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 // source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
val reader = MultiDexFileReader.open(dexFile.readBytes()) val jarFilePath = File(jarFile).toPath()
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
val handler = BaksmaliBaseDexExceptionHandler() val handler = BaksmaliBaseDexExceptionHandler()
Dex2jar Dex2jar
.from(reader) .from(reader)
@@ -72,10 +73,10 @@ object PackageTools {
.noCode(false) .noCode(false)
.skipExceptions(false) .skipExceptions(false)
.dontSanitizeNames(true) .dontSanitizeNames(true)
.to(jarFile) .to(jarFilePath)
if (handler.hasException()) { if (handler.hasException()) {
val rootPath = Path(applicationDirs.extensionsRoot) val rootPath = Path(applicationDirs.extensionsRoot)
val errorFile: Path = rootPath.resolve("${dexFile.nameWithoutExtension}-error.txt") val errorFile: Path = rootPath.resolve("$fileNameWithoutType-error.txt")
logger.error { logger.error {
""" """
Detail Error Information in File ${errorFile.relativeTo(rootPath)} Detail Error Information in File ${errorFile.relativeTo(rootPath)}
@@ -88,55 +89,56 @@ object PackageTools {
} }
handler.dump(errorFile, emptyArray<String>()) handler.dump(errorFile, emptyArray<String>())
} else { } else {
BytecodeEditor.fixAndroidClasses(jarFile) BytecodeEditor.fixAndroidClasses(jarFilePath)
} }
} }
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */ /** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
fun getPackageInfo(apkFile: Path): PackageInfo = fun getPackageInfo(apkFilePath: String): PackageInfo {
ApkParsers.getMetaInfo(apkFile.toFile()).toPackageInfo(apkFile.toFile()).apply { val apk = File(apkFilePath)
ApkFile(apkFile.toFile()).use { parsed -> return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
val dbFactory = DocumentBuilderFactory.newInstance() val parsed = ApkFile(apk)
val dBuilder = dbFactory.newDocumentBuilder() val dbFactory = DocumentBuilderFactory.newInstance()
val doc = val dBuilder = dbFactory.newDocumentBuilder()
parsed.manifestXml.byteInputStream().use { val doc =
dBuilder.parse(it) parsed.manifestXml.byteInputStream().use {
} dBuilder.parse(it)
}
logger.trace { parsed.manifestXml } logger.trace { parsed.manifestXml }
applicationInfo.metaData = applicationInfo.metaData =
Bundle().apply { Bundle().apply {
val appTag = doc.getElementsByTagName("application").item(0) val appTag = doc.getElementsByTagName("application").item(0)
appTag appTag
?.childNodes ?.childNodes
?.toList() ?.toList()
.orEmpty() .orEmpty()
.asSequence() .asSequence()
.filter { .filter {
it.nodeType == Node.ELEMENT_NODE it.nodeType == Node.ELEMENT_NODE
}.map { }.map {
it as Element it as Element
}.filter { }.filter {
it.tagName == "meta-data" it.tagName == "meta-data"
}.forEach { }.forEach {
putString( putString(
it.attributes.getNamedItem("android:name").nodeValue, it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue, it.attributes.getNamedItem("android:value").nodeValue,
) )
} }
} }
signatures = signatures =
( (
parsed.apkSingers.flatMap { it.certificateMetas } parsed.apkSingers.flatMap { it.certificateMetas }
// + parsed.apkV2Singers.flatMap { it.certificateMetas } // + parsed.apkV2Singers.flatMap { it.certificateMetas }
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72 ) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
.map { Signature(it.data) } .map { Signature(it.data) }
.toTypedArray() .toTypedArray()
}
} }
}
fun getSignatureHash(pkgInfo: PackageInfo): String? { fun getSignatureHash(pkgInfo: PackageInfo): String? {
val signatures = pkgInfo.signatures val signatures = pkgInfo.signatures
@@ -154,19 +156,19 @@ object PackageTools {
* It may return an instance of HttpSource or SourceFactory depending on the extension. * It may return an instance of HttpSource or SourceFactory depending on the extension.
*/ */
fun loadExtensionSources( fun loadExtensionSources(
jar: Path, jarPath: String,
className: String, className: String,
): Any { ): Any {
try { try {
logger.debug { "loading jar with path: ${jar.absolutePathString()}" } logger.debug { "loading jar with path: $jarPath" }
val classLoader = jarLoaderMap[jar.absolutePathString()] ?: ChildFirstURLClassLoader(arrayOf<URL>(jar.toUri().toURL())) val classLoader = jarLoaderMap[jarPath] ?: ChildFirstURLClassLoader(arrayOf<URL>(Path(jarPath).toUri().toURL()))
val classToLoad = Class.forName(className, false, classLoader) val classToLoad = Class.forName(className, false, classLoader)
jarLoaderMap[jar.absolutePathString()] = classLoader jarLoaderMap[jarPath] = classLoader
return classToLoad.getDeclaredConstructor().newInstance() return classToLoad.getDeclaredConstructor().newInstance()
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "Failed to load jar with path: ${jar.absolutePathString()}" } logger.error(e) { "Failed to load jar with path: $jarPath" }
throw e throw e
} }
} }

View File

@@ -1,71 +0,0 @@
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<IconCandidate> =
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
}

View File

@@ -20,8 +20,6 @@ import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.io.path.Path
import kotlin.io.path.div
object GetSource { object GetSource {
private val logger = KotlinLogging.logger { } private val logger = KotlinLogging.logger { }
@@ -51,7 +49,7 @@ object GetSource {
?: throw NullPointerException("Missing apkName") ?: throw NullPointerException("Missing apkName")
val className = extensionRecord[ExtensionTable.classFQName] val className = extensionRecord[ExtensionTable.classFQName]
val jarName = apkName.substringBefore(".apk") + ".jar" val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = Path(applicationDirs.extensionsRoot) / jarName val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
when (val instance = loadExtensionSources(jarPath, className)) { when (val instance = loadExtensionSources(jarPath, className)) {
is Source -> listOf(instance) is Source -> listOf(instance)

View File

@@ -12,7 +12,6 @@ data class ExtensionInfo(
val name: String, val name: String,
val pkgName: String, val pkgName: String,
val apkUrl: String, val apkUrl: String,
val jarUrl: String?,
val iconUrl: String, val iconUrl: String,
val extensionLib: String, val extensionLib: String,
val versionCode: Long, val versionCode: Long,

View File

@@ -24,7 +24,6 @@ object ExtensionTable : IntIdTable() {
val name = varchar("name", 128) val name = varchar("name", 128)
val pkgName = varchar("pkg_name", 128) val pkgName = varchar("pkg_name", 128)
val apkUrl = varchar("apk_url", 2048).nullable() val apkUrl = varchar("apk_url", 2048).nullable()
val jarUrl = varchar("jar_url", 2048).nullable()
val extensionLib = varchar("extension_lib", 16).nullable() val extensionLib = varchar("extension_lib", 16).nullable()
val versionName = varchar("version_name", 16) val versionName = varchar("version_name", 16)
val versionCode = long("version_code") val versionCode = long("version_code")

View File

@@ -1,19 +0,0 @@
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",
)

View File

@@ -27,7 +27,6 @@ import suwayomi.tachidesk.manga.impl.Source.getSourceList
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
import suwayomi.tachidesk.manga.impl.extension.Extension.updateExtension 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.extension.ExtensionsList.getExtensionList
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrNull import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrNull
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
@@ -51,8 +50,6 @@ class TestExtensionCompatibility {
private val chaptersToFetch = mutableListOf<Triple<HttpSource, SManga, SChapter>>() private val chaptersToFetch = mutableListOf<Triple<HttpSource, SManga, SChapter>>()
private val chaptersPageListFailedToFetch = mutableListOf<Triple<HttpSource, Pair<SManga, SChapter>, Exception>>() private val chaptersPageListFailedToFetch = mutableListOf<Triple<HttpSource, Pair<SManga, SChapter>, Exception>>()
val repos = listOf<String>()
@BeforeAll @BeforeAll
fun setup() { fun setup() {
val dataRoot = File(BASE_PATH).absolutePath val dataRoot = File(BASE_PATH).absolutePath
@@ -60,14 +57,6 @@ class TestExtensionCompatibility {
Looper.clearMainLooperForTest() Looper.clearMainLooperForTest()
SettingsRegistry.clear() SettingsRegistry.clear()
applicationSetup() applicationSetup()
repos.forEach {
val store =
runBlocking {
ExtensionStoreService.fetch(it)
}
ExtensionStoreService.upsert(store)
}
ExtensionStoreService.syncDbToPrefs()
setLoggingEnabled(false) setLoggingEnabled(false)
runBlocking { runBlocking {
@@ -83,10 +72,7 @@ class TestExtensionCompatibility {
} }
else -> { else -> {
try { uninstallExtension(it.pkgName)
uninstallExtension(it.pkgName)
} catch (_: Exception) {
}
installExtension(it.pkgName) installExtension(it.pkgName)
} }
} }
@@ -149,9 +135,9 @@ class TestExtensionCompatibility {
repeat { source.getMangaUpdate(manga, emptyList(), true, true) } repeat { source.getMangaUpdate(manga, emptyList(), true, true) }
} catch (e: Exception) { } catch (e: Exception) {
logger.warn { logger.warn {
"Failed to fetch manga info and chapters from $source for ${manga.title} (${source.getMangaUrl( "Failed to fetch manga info and chapters from $source for ${manga.title} (${source.mangaDetailsRequest(
manga, manga,
)}): ${e.message}" ).url}): ${e.message}"
} }
mangaFailedToFetch += Triple(source, manga, e) mangaFailedToFetch += Triple(source, manga, e)
} }
@@ -161,7 +147,7 @@ class TestExtensionCompatibility {
File("$BASE_PATH/MangaFailedToFetch.txt").writeText( File("$BASE_PATH/MangaFailedToFetch.txt").writeText(
mangaFailedToFetch.joinToString("\n") { (source, manga, exception) -> mangaFailedToFetch.joinToString("\n") { (source, manga, exception) ->
"${source.name} (${source.lang}, ${source.id}):" + "${source.name} (${source.lang}, ${source.id}):" +
" ${manga.title} (${source.getMangaUrl(manga)}):" + " ${manga.title} (${source.mangaDetailsRequest(manga).url}):" +
" ${exception.message}" " ${exception.message}"
}, },
) )
@@ -177,9 +163,9 @@ class TestExtensionCompatibility {
repeat { source.getPageList(chapter) } repeat { source.getPageList(chapter) }
} catch (e: Exception) { } catch (e: Exception) {
logger.warn { logger.warn {
"Failed to fetch manga info from $source for ${manga.title} (${source.getMangaUrl( "Failed to fetch manga info from $source for ${manga.title} (${source.mangaDetailsRequest(
manga, manga,
)}): ${e.message}" ).url}): ${e.message}"
} }
chaptersPageListFailedToFetch += Triple(source, manga to chapter, e) chaptersPageListFailedToFetch += Triple(source, manga to chapter, e)
} }
@@ -190,7 +176,7 @@ class TestExtensionCompatibility {
File("$BASE_PATH/ChapterPageListFailedToFetch.txt").writeText( File("$BASE_PATH/ChapterPageListFailedToFetch.txt").writeText(
chaptersPageListFailedToFetch.joinToString("\n") { (source, manga, exception) -> chaptersPageListFailedToFetch.joinToString("\n") { (source, manga, exception) ->
"${source.name} (${source.lang}, ${source.id}):" + "${source.name} (${source.lang}, ${source.id}):" +
" ${manga.first.title} (${source.getMangaUrl(manga.first)}):" + " ${manga.first.title} (${source.mangaDetailsRequest(manga.first).url}):" +
" ${manga.second.name} (${manga.second.url}): ${exception.message}" " ${manga.second.name} (${manga.second.url}): ${exception.message}"
}, },
) )