mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-13 07:44:34 -05:00
Compare commits
10 Commits
renovate/l
...
jar_instal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84ab6e426 | ||
|
|
a830782b4b | ||
|
|
c0586e7f36 | ||
|
|
47f0e60659 | ||
|
|
d65a004a17 | ||
|
|
c18155cf06 | ||
|
|
4fdef7d17d | ||
|
|
a66649715e | ||
|
|
393a7349ef | ||
|
|
b0bc8c6fb3 |
@@ -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
|
||||
- .
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user