Compare commits

...

10 Commits

Author SHA1 Message Date
Syer10
c84ab6e426 Simplify installExtension 2026-07-12 23:47:08 -04:00
Syer10
a830782b4b Improvements and fixes 2026-07-12 23:43:11 -04:00
Syer10
c0586e7f36 Optimize imports 2026-07-12 23:01:13 -04:00
Syer10
47f0e60659 Remove test function 2026-07-12 23:00:44 -04:00
Syer10
d65a004a17 Utility function 2026-07-12 22:58:36 -04:00
Syer10
c18155cf06 Lint 2026-07-12 22:57:38 -04:00
Syer10
4fdef7d17d Return pkgName for extension install 2026-07-12 22:56:28 -04:00
Syer10
a66649715e Changelog 2026-07-12 22:34:30 -04:00
Syer10
393a7349ef Install JARs directly 2026-07-12 22:13:54 -04:00
Constantin Piber
b0bc8c6fb3 fix: Do not shadow exceptions on ext install (#2180)
Depending on where the exception occurred, the uninstall may fail,
because the extension is not installed in the first place, which hides
the actual error
2026-07-12 16:21:47 -04:00
17 changed files with 703 additions and 228 deletions

View File

@@ -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
- .

View File

@@ -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",
]

View File

@@ -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,

View File

@@ -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),

View File

@@ -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(),

View File

@@ -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)
}
}
},

View File

@@ -17,9 +17,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 +25,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 +38,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,238 +48,448 @@ 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 {
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]
val fetchToTemp: suspend (String) -> Path = { url ->
val name = Uri.parse(jarUrl).lastPathSegment!!
val savePath = Path(applicationDirs.tempRoot) / "extensions" / name
// download jar file
downloadExtension(url, savePath)
return installAPK {
val apkName = Uri.parse(apkUrl).lastPathSegment!!
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
// download apk file
downloadAPKFile(apkUrl, apkSavePath)
savePath
}
apkSavePath
return when {
jarUrl != null -> {
installJAR { fetchToTemp(jarUrl) }
}
apkUrl != null -> {
installAPK { fetchToTemp(apkUrl) }
}
else -> {
throw NullPointerException("Could not find extension url for $pkgName")
}
}
}
suspend fun installExternalExtension(
inputStream: InputStream,
apkName: String,
): Int =
installAPK(true) {
val rootPath = Path(applicationDirs.extensionsRoot)
val downloadedFile = rootPath.resolve(apkName).normalize()
extensionName: String,
): String {
val copyToExtensionsRoot = {
val rootPath = Path(applicationDirs.tempRoot) / "extensions"
val downloadedFile = rootPath.resolve(extensionName).normalize()
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
"File '$apkName' is not a valid extension file"
"File '$extensionName' is not a valid extension file"
}
logger.debug { "Saving apk at $apkName" }
// download apk file
downloadedFile.outputStream().sink().buffer().use { sink ->
inputStream.source().use { source ->
sink.writeAll(source)
sink.flush()
logger.debug { "Saving jar at $extensionName" }
// download jar file
downloadedFile.createParentDirectories()
downloadedFile.outputStream().buffered().use { out ->
inputStream.use {
it.copyTo(out)
}
}
downloadedFile.absolutePathString()
downloadedFile
}
return when {
extensionName.endsWith(".jar") -> installJAR(true, copyToExtensionsRoot)
extensionName.endsWith(".apk") -> installAPK(true, copyToExtensionsRoot)
else -> throw NullPointerException("Could not find extension type for $extensionName")
}
}
suspend fun installAPK(
forceReinstall: Boolean = false,
fetcher: suspend () -> String,
): Int {
val apkFilePath = fetcher()
val apkName = File(apkFilePath).name
fetcher: suspend () -> Path,
): String {
val apkFile = fetcher()
val packageInfo = getPackageInfo(apkFile)
val pkgName = packageInfo.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 =
transaction {
ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.firstOrNull()
ExtensionTable.select(ExtensionTable.isInstalled).where { ExtensionTable.pkgName eq pkgName }.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false
val fileNameWithoutType = apkName.substringBefore(".apk")
val extensionsRoot = Path(applicationDirs.extensionsRoot)
val jarFile = extensionsRoot / (apkFile.nameWithoutExtension + ".jar")
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
val jarFilePath = "$dirPathWithoutType.jar"
val packageInfo = getPackageInfo(apkFilePath)
val pkgName = packageInfo.packageName
if (isInstalled && forceReinstall) {
uninstallExtension(pkgName)
} else if (isInstalled) {
apkFile.deleteExisting()
return pkgName
}
if (!isInstalled || forceReinstall) {
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
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",
)
}
// 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",
)
}
// TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo)
// 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")
// }
// 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)
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_CONTENT_WARNING)
.getString(METADATA_NSFW)
?.toIntOrNull()
?: 0
if (contentWarning == 0) {
contentWarning = packageInfo.applicationInfo.metaData
.getString(METADATA_NSFW)
?.toIntOrNull()
?: 0
}
}
}
val sourceClass =
packageInfo.applicationInfo.metaData
.getString(METADATA_SOURCE_CLASS)!!
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
}
val sourceClass =
logger.debug { "Main class for extension is $className" }
dex2jar(apkFile, jarFile)
extractAssetsFromApk(apkFile, jarFile)
extractAndCacheApkIcon(apkFile, pkgName)
// clean up
apkFile.deleteExisting()
try {
val extensionName =
packageInfo.applicationInfo.metaData.getString(METADATA_NAME)
?: packageInfo.applicationInfo.nonLocalizedLabel
.toString()
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
packageInfo.applicationInfo.metaData
.getString(METADATA_SOURCE_CLASS)!!
.trim()
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: packageInfo.versionName.substringBeforeLast('.')
val className =
if (sourceClass.startsWith(".")) {
packageInfo.packageName + sourceClass
} else {
sourceClass
}
logger.debug { "Main class for extension is $className" }
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
extractAssetsFromApk(apkFilePath, jarFilePath)
extractAndCacheApkIcon(apkFilePath, packageInfo.packageName)
// clean up
File(apkFilePath).delete()
setupJar(
jarFile,
className,
extensionName,
extensionLibVersion,
apkFile.name,
pkgName,
packageInfo.versionName,
packageInfo.versionCode,
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<Source> =
when (extensionMainClassInstance) {
is Source -> listOf(extensionMainClassInstance)
is SourceFactory -> extensionMainClassInstance.createSources()
else -> throw RuntimeException("Unknown source class type! ${extensionMainClassInstance.javaClass}")
uninstallExtension(pkgName)
} catch (_: Throwable) {
}
throw e
}
return pkgName
}
suspend fun installJAR(
forceReinstall: Boolean = false,
fetcher: suspend () -> Path,
): String {
val tmpJarFile = fetcher()
val jarFile = Path(applicationDirs.extensionsRoot) / tmpJarFile.name
val tmpIcon = jarFile.parent / (jarFile.nameWithoutExtension + ".icon")
val jarZip =
ZipFile
.builder()
.setPath(tmpJarFile)
.get()
val manifest: AndroidManifestParser.AndroidManifest
jarZip.use { jarZip ->
manifest =
jarZip
.getInputStream(jarZip.getEntry("AndroidManifest.xml"))
.use {
AndroidManifestParser.parse(it)
}
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}" }
try {
ResourceArscIconParser.extractIcon(jarZip).use {
tmpIcon.outputStream().use { out ->
it.copyTo(out)
}
}
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)
throw e
} catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from JAR ${manifest.packageName}" }
}
}
val pkgName: String = manifest.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 =
transaction {
ExtensionTable
.select(ExtensionTable.isInstalled)
.where { ExtensionTable.pkgName eq pkgName }
.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false
if (isInstalled && forceReinstall) {
uninstallExtension(pkgName)
} else if (isInstalled) {
tmpJarFile.deleteExisting()
tmpIcon.deleteIfExists()
return pkgName
}
if (!manifest.usesFeatures.any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
// Validate lib version
val libVersion = manifest.versionName!!.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
)
}
if (tmpIcon.exists()) {
try {
cacheIcon(pkgName, tmpIcon.inputStream())
} catch (e: Exception) {
logger.warn(e) { "Failed to copy icon from JAR ${manifest.packageName}" }
}
}
tmpIcon.deleteIfExists()
tmpJarFile.copyTo(jarFile)
tmpJarFile.deleteExisting()
// TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo)
//
// if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) {
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
fun List<AndroidManifestParser.MetaData>.getString(name: String): String? = this.find { it.name == name }?.value
var contentWarning =
manifest.application!!
.metaData
.getString(METADATA_CONTENT_WARNING)
?.toIntOrNull()
if (contentWarning == null) {
contentWarning = manifest.application.metaData
.getString(METADATA_NSFW)
?.toIntOrNull()
?: 0
}
val sourceClass =
manifest.application.metaData
.getString(METADATA_SOURCE_CLASS)!!
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
}
logger.debug { "Main class for extension is $className" }
try {
val extensionName =
manifest.application.metaData.getString(METADATA_NAME)
?: manifest.application.label!!
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
manifest.application.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: manifest.versionName!!.substringBeforeLast('.')
setupJar(
jarFile,
className,
extensionName,
extensionLibVersion,
jarFile.name.removeSuffix(".jar") + ".apk",
pkgName,
manifest.versionName!!,
manifest.versionCode!!,
contentWarning,
)
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
jarFile.deleteIfExists()
try {
uninstallExtension(pkgName)
} catch (_: Exception) {
}
throw e
}
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(
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<Icon>()
.mapNotNull { it.data?.let { data -> data to it.density } }
@@ -290,31 +500,48 @@ object Extension {
logger.warn { "No icon found in APK $pkgName" }
return
}
File(iconCacheDir).mkdirs()
clearCachedImage(iconCacheDir, pkgName)
saveImage("$iconCacheDir/$pkgName", iconData.inputStream(), null)
cacheIcon(pkgName, iconData.inputStream())
} catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from APK $pkgName" }
}
}
private fun extractAssetsFromApk(
apkPath: String,
jarPath: String,
private fun extractAndCacheJarIcon(
zipFile: ZipFile,
pkgName: String,
) {
val apkFile = File(apkPath)
val jarFile = File(jarPath)
try {
val iconStream = ResourceArscIconParser.extractIcon(zipFile)
cacheIcon(pkgName, iconStream)
} catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from JAR $pkgName" }
}
}
val assetsFolder = File("${apkFile.parent}/${apkFile.nameWithoutExtension}_assets")
assetsFolder.mkdir()
private fun cacheIcon(
pkgName: String,
inputStream: InputStream,
) {
val iconCacheDir = Path(applicationDirs.extensionsRoot) / "icon"
iconCacheDir.createDirectories()
clearCachedImage(iconCacheDir.absolutePathString(), pkgName)
saveImage("$iconCacheDir/$pkgName", inputStream, null)
}
@OptIn(ExperimentalPathApi::class)
private fun extractAssetsFromApk(
apkFile: Path,
jarFile: Path,
) {
val assetsFolder = apkFile.parent / "${apkFile.nameWithoutExtension}_assets"
assetsFolder.createDirectories()
ZipInputStream(apkFile.inputStream()).use { zipInputStream ->
var zipEntry = zipInputStream.nextEntry
while (zipEntry != null) {
if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) {
val assetFile = File(assetsFolder, zipEntry.name)
assetFile.parentFile.mkdirs()
FileOutputStream(assetFile).use { outputStream ->
val assetFile = assetsFolder / zipEntry.name
assetFile.createParentDirectories()
assetFile.outputStream().use { outputStream ->
zipInputStream.copyTo(outputStream)
}
}
@@ -322,9 +549,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/")) {
@@ -333,8 +560,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)
@@ -345,17 +572,18 @@ object Extension {
}
}
jarFile.delete()
tempJarFile.renameTo(jarFile)
jarFile.deleteIfExists()
tempJarFile.copyTo(jarFile)
tempJarFile.deleteExisting()
assetsFolder.deleteRecursively()
}
private val network: NetworkHelper by injectLazy()
private suspend fun downloadAPKFile(
private suspend fun downloadExtension(
url: String,
savePath: String,
savePath: Path,
) {
val response =
network.client
@@ -363,11 +591,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)
}
}
}
@@ -379,7 +606,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
@@ -401,18 +628,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 {

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<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,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,15 +88,14 @@ object PackageTools {
}
handler.dump(errorFile, emptyArray<String>())
} else {
BytecodeEditor.fixAndroidClasses(jarFilePath)
BytecodeEditor.fixAndroidClasses(jarFile)
}
}
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
fun getPackageInfo(apkFilePath: String): PackageInfo {
val apk = File(apkFilePath)
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
val parsed = ApkFile(apk)
fun getPackageInfo(apkFile: Path): PackageInfo =
ApkParsers.getMetaInfo(apkFile.toFile()).toPackageInfo(apkFile.toFile()).apply {
val parsed = ApkFile(apkFile.toFile())
val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder()
val doc =
@@ -138,7 +136,6 @@ object PackageTools {
.map { Signature(it.data) }
.toTypedArray()
}
}
fun getSignatureHash(pkgInfo: PackageInfo): String? {
val signatures = pkgInfo.signatures
@@ -156,19 +153,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<URL>(Path(jarPath).toUri().toURL()))
logger.debug { "loading jar with path: ${jar.absolutePathString()}" }
val classLoader = jarLoaderMap[jar.absolutePathString()] ?: ChildFirstURLClassLoader(arrayOf<URL>(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
}
}

View File

@@ -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<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,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)

View File

@@ -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,

View File

@@ -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")

View File

@@ -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",
)