mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-14 08:14:34 -05:00
Compare commits
3 Commits
renovate/m
...
v2.3.2243
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d583ca4a7 | ||
|
|
d34a486dca | ||
|
|
b0bc8c6fb3 |
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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.2238"
|
Example: "v2.3.2243"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -12,6 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
### Changed
|
### Changed
|
||||||
- .
|
- .
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- .
|
||||||
|
|
||||||
|
## [v2.3.2243] - 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- (**Extension**) Add ability to install jar versions of extensions
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- (**Extension**) Fix missing memo field in getPageList
|
- (**Extension**) Fix missing memo field in getPageList
|
||||||
|
|
||||||
@@ -500,7 +508,8 @@ 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.2238...HEAD
|
[unreleased]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2243...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
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ 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"
|
||||||
@@ -215,6 +216,7 @@ shared = [
|
|||||||
"dex2jar-translator",
|
"dex2jar-translator",
|
||||||
"dex2jar-tools",
|
"dex2jar-tools",
|
||||||
"apk-parser",
|
"apk-parser",
|
||||||
|
"axml",
|
||||||
"jackson-annotations",
|
"jackson-annotations",
|
||||||
"jcef",
|
"jcef",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -179,10 +179,10 @@ class ExtensionMutation {
|
|||||||
val (clientMutationId, extensionFile) = input
|
val (clientMutationId, extensionFile) = input
|
||||||
|
|
||||||
return future {
|
return future {
|
||||||
Extension.installExternalExtension(extensionFile.content(), extensionFile.filename())
|
val pkgName = Extension.installExternalExtension(extensionFile.content(), extensionFile.filename())
|
||||||
|
|
||||||
val dbExtension =
|
val dbExtension =
|
||||||
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq extensionFile.filename() }.first() }
|
transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }
|
||||||
|
|
||||||
InstallExternalExtensionPayload(
|
InstallExternalExtensionPayload(
|
||||||
clientMutationId,
|
clientMutationId,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ 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,
|
||||||
@@ -122,6 +123,7 @@ 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)
|
||||||
@@ -150,6 +152,7 @@ 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"))
|
||||||
@@ -175,6 +178,7 @@ 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),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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(
|
||||||
@@ -56,6 +57,7 @@ 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(),
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ object ExtensionController {
|
|||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
Extension.installExtension(pkgName)
|
Extension.installExtension(pkgName)
|
||||||
}.thenApply {
|
|
||||||
ctx.status(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,8 +97,6 @@ object ExtensionController {
|
|||||||
uploadedFile.content(),
|
uploadedFile.content(),
|
||||||
uploadedFile.filename(),
|
uploadedFile.filename(),
|
||||||
)
|
)
|
||||||
}.thenApply {
|
|
||||||
ctx.status(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -126,8 +122,6 @@ object ExtensionController {
|
|||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
Extension.updateExtension(pkgName)
|
Extension.updateExtension(pkgName)
|
||||||
}.thenApply {
|
|
||||||
ctx.status(it)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* 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
|
||||||
@@ -17,9 +18,7 @@ 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 okio.buffer
|
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||||
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
|
||||||
@@ -27,6 +26,7 @@ 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,6 +39,7 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
|||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.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
|
||||||
@@ -48,238 +49,421 @@ 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()
|
||||||
|
|
||||||
suspend fun installExtension(pkgName: String): Int {
|
private suspend fun fetchExtensionFile(url: String): Path {
|
||||||
|
val name = Uri.parse(url).lastPathSegment!!
|
||||||
|
val savePath = Path(applicationDirs.tempRoot) / "extensions" / name
|
||||||
|
// download jar file
|
||||||
|
downloadExtension(url, savePath)
|
||||||
|
|
||||||
|
return savePath
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun installExtension(pkgName: String): String {
|
||||||
logger.debug { "Installing $pkgName" }
|
logger.debug { "Installing $pkgName" }
|
||||||
val apkUrl =
|
val extension =
|
||||||
transaction {
|
transaction {
|
||||||
ExtensionTable
|
ExtensionTable
|
||||||
.select(ExtensionTable.apkUrl)
|
.select(ExtensionTable.apkUrl, ExtensionTable.jarUrl)
|
||||||
.where { ExtensionTable.pkgName eq pkgName }
|
.where { ExtensionTable.pkgName eq pkgName }
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
?.get(ExtensionTable.apkUrl)
|
} ?: throw NullPointerException("Could not find extension for $pkgName")
|
||||||
} ?: throw NullPointerException("Could not find extension $pkgName")
|
val jarUrl = extension[ExtensionTable.jarUrl]
|
||||||
|
val apkUrl = extension[ExtensionTable.apkUrl]
|
||||||
|
|
||||||
return installAPK {
|
return when {
|
||||||
val apkName = Uri.parse(apkUrl).lastPathSegment!!
|
jarUrl != null -> {
|
||||||
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
|
installExtension {
|
||||||
// download apk file
|
val jar = fetchExtensionFile(jarUrl)
|
||||||
downloadAPKFile(apkUrl, apkSavePath)
|
val manifest = extractAndParseAndroidManifest(jar)
|
||||||
|
ExtensionPackage.Jar(jar, manifest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
apkSavePath
|
apkUrl != null -> {
|
||||||
|
installExtension {
|
||||||
|
val apk = fetchExtensionFile(apkUrl)
|
||||||
|
val packageInfo = getPackageInfo(apk)
|
||||||
|
ExtensionPackage.Apk(apk, packageInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
throw NullPointerException("Could not find extension url for $pkgName")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyToExtensionsRoot(
|
||||||
|
inputStream: InputStream,
|
||||||
|
extensionName: String,
|
||||||
|
): Path {
|
||||||
|
val rootPath = Path(applicationDirs.tempRoot) / "extensions"
|
||||||
|
val downloadedFile = rootPath.resolve(extensionName).normalize()
|
||||||
|
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
|
||||||
|
"File '$extensionName' is not a valid extension file"
|
||||||
|
}
|
||||||
|
logger.debug { "Saving jar at $extensionName" }
|
||||||
|
// download jar file
|
||||||
|
downloadedFile.createParentDirectories()
|
||||||
|
downloadedFile.outputStream().buffered().use { out ->
|
||||||
|
inputStream.use {
|
||||||
|
it.copyTo(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return downloadedFile
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun installExternalExtension(
|
suspend fun installExternalExtension(
|
||||||
inputStream: InputStream,
|
inputStream: InputStream,
|
||||||
apkName: String,
|
extensionName: String,
|
||||||
): Int =
|
): String =
|
||||||
installAPK(true) {
|
when {
|
||||||
val rootPath = Path(applicationDirs.extensionsRoot)
|
extensionName.endsWith(".jar") -> {
|
||||||
val downloadedFile = rootPath.resolve(apkName).normalize()
|
installExtension(
|
||||||
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
|
true,
|
||||||
"File '$apkName' is not a valid extension file"
|
{
|
||||||
}
|
val jar = copyToExtensionsRoot(inputStream, extensionName)
|
||||||
logger.debug { "Saving apk at $apkName" }
|
val manifest = extractAndParseAndroidManifest(jar)
|
||||||
// download apk file
|
ExtensionPackage.Jar(jar, manifest)
|
||||||
downloadedFile.outputStream().sink().buffer().use { sink ->
|
},
|
||||||
inputStream.source().use { source ->
|
|
||||||
sink.writeAll(source)
|
|
||||||
sink.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
downloadedFile.absolutePathString()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun installAPK(
|
|
||||||
forceReinstall: Boolean = false,
|
|
||||||
fetcher: suspend () -> String,
|
|
||||||
): Int {
|
|
||||||
val apkFilePath = fetcher()
|
|
||||||
val apkName = File(apkFilePath).name
|
|
||||||
|
|
||||||
// check if we don't have the extension already installed
|
|
||||||
// if it's installed and we want to update, it first has to be uninstalled
|
|
||||||
val isInstalled =
|
|
||||||
transaction {
|
|
||||||
ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.firstOrNull()
|
|
||||||
}?.get(ExtensionTable.isInstalled) ?: false
|
|
||||||
|
|
||||||
val fileNameWithoutType = apkName.substringBefore(".apk")
|
|
||||||
|
|
||||||
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
|
|
||||||
val jarFilePath = "$dirPathWithoutType.jar"
|
|
||||||
|
|
||||||
val packageInfo = getPackageInfo(apkFilePath)
|
|
||||||
val pkgName = packageInfo.packageName
|
|
||||||
if (isInstalled && forceReinstall) {
|
|
||||||
uninstallExtension(pkgName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isInstalled || forceReinstall) {
|
|
||||||
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
|
|
||||||
throw Exception("This apk is not a Tachiyomi extension")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate lib version
|
|
||||||
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
|
|
||||||
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
|
||||||
throw Exception(
|
|
||||||
"Lib version is $libVersion, while only versions " +
|
|
||||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: allow trusting keys
|
extensionName.endsWith(".apk") -> {
|
||||||
// val signatureHash = getSignatureHash(packageInfo)
|
installExtension(
|
||||||
|
true,
|
||||||
// if (signatureHash == null) {
|
{
|
||||||
// throw Exception("Package $pkgName isn't signed")
|
val apk = copyToExtensionsRoot(inputStream, extensionName)
|
||||||
// } else if (signatureHash !in trustedSignatures) {
|
val packageInfo = getPackageInfo(apk)
|
||||||
// throw Exception("This apk is not a signed with the official tachiyomi signature")
|
ExtensionPackage.Apk(apk, packageInfo)
|
||||||
// }
|
},
|
||||||
|
)
|
||||||
var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING)
|
|
||||||
if (contentWarning == 0) {
|
|
||||||
contentWarning = packageInfo.applicationInfo.metaData
|
|
||||||
.getString(METADATA_CONTENT_WARNING)
|
|
||||||
?.toIntOrNull()
|
|
||||||
?: 0
|
|
||||||
if (contentWarning == 0) {
|
|
||||||
contentWarning = packageInfo.applicationInfo.metaData
|
|
||||||
.getString(METADATA_NSFW)
|
|
||||||
?.toIntOrNull()
|
|
||||||
?: 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val sourceClass =
|
else -> {
|
||||||
packageInfo.applicationInfo.metaData
|
throw NullPointerException("Could not find extension type for $extensionName")
|
||||||
.getString(METADATA_SOURCE_CLASS)!!
|
}
|
||||||
.trim()
|
}
|
||||||
|
|
||||||
val className =
|
sealed class ExtensionPackage {
|
||||||
if (sourceClass.startsWith(".")) {
|
abstract val file: Path
|
||||||
packageInfo.packageName + sourceClass
|
abstract val metadata: PackageMetadata
|
||||||
} else {
|
|
||||||
sourceClass
|
// Abstract hook for type-specific preprocessing
|
||||||
|
abstract suspend fun prepareJarAndIcons(extensionsRoot: Path): Path
|
||||||
|
|
||||||
|
class Apk(
|
||||||
|
override val file: Path,
|
||||||
|
val packageInfo: PackageInfo,
|
||||||
|
) : ExtensionPackage() {
|
||||||
|
override val metadata =
|
||||||
|
PackageMetadata(
|
||||||
|
packageName = packageInfo.packageName,
|
||||||
|
versionName = packageInfo.versionName,
|
||||||
|
versionCode = packageInfo.versionCode,
|
||||||
|
reqFeatures = packageInfo.reqFeatures.orEmpty().map { it.name },
|
||||||
|
metaData = MetadataProvider.FromPackageInfo(packageInfo.applicationInfo.metaData),
|
||||||
|
label = packageInfo.applicationInfo.nonLocalizedLabel?.toString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun prepareJarAndIcons(extensionsRoot: Path): Path {
|
||||||
|
val jarFile = extensionsRoot / (file.nameWithoutExtension + ".jar")
|
||||||
|
dex2jar(file, jarFile)
|
||||||
|
extractAssetsFromApk(file, jarFile)
|
||||||
|
extractAndCacheApkIcon(file, metadata.packageName)
|
||||||
|
file.deleteExisting()
|
||||||
|
return jarFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Jar(
|
||||||
|
override val file: Path,
|
||||||
|
val manifest: AndroidManifestParser.AndroidManifest,
|
||||||
|
) : ExtensionPackage() {
|
||||||
|
override val metadata =
|
||||||
|
PackageMetadata(
|
||||||
|
packageName = manifest.packageName,
|
||||||
|
versionName = manifest.versionName!!,
|
||||||
|
versionCode = manifest.versionCode!!,
|
||||||
|
reqFeatures = manifest.usesFeatures.mapNotNull { it.name },
|
||||||
|
metaData = MetadataProvider.FromManifest(manifest.application!!.metaData),
|
||||||
|
label = manifest.application.label,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun prepareJarAndIcons(extensionsRoot: Path): Path {
|
||||||
|
val jarFile = extensionsRoot / file.name
|
||||||
|
|
||||||
|
ZipFile.builder().setPath(file).get().use { jarZip ->
|
||||||
|
try {
|
||||||
|
cacheIcon(
|
||||||
|
metadata.packageName,
|
||||||
|
ResourceArscIconParser.extractIcon(jarZip),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn(e) { "Failed to extract icon from JAR ${metadata.packageName}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug { "Main class for extension is $className" }
|
file.copyTo(jarFile)
|
||||||
|
file.deleteExisting()
|
||||||
|
return jarFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
data class PackageMetadata(
|
||||||
extractAssetsFromApk(apkFilePath, jarFilePath)
|
val packageName: String,
|
||||||
extractAndCacheApkIcon(apkFilePath, packageInfo.packageName)
|
val versionName: String,
|
||||||
|
val versionCode: Int,
|
||||||
|
val reqFeatures: List<String>,
|
||||||
|
val metaData: MetadataProvider,
|
||||||
|
val label: String?,
|
||||||
|
)
|
||||||
|
|
||||||
// clean up
|
sealed interface MetadataProvider {
|
||||||
File(apkFilePath).delete()
|
fun getString(key: String): String?
|
||||||
|
|
||||||
|
fun getInt(key: String): Int
|
||||||
|
|
||||||
|
class FromPackageInfo(
|
||||||
|
private val bundle: android.os.Bundle,
|
||||||
|
) : MetadataProvider {
|
||||||
|
override fun getString(key: String): String? = bundle.getString(key)
|
||||||
|
|
||||||
|
override fun getInt(key: String): Int = bundle.getInt(key) ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
class FromManifest(
|
||||||
|
private val list: List<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,
|
||||||
|
fetchPackage: suspend () -> ExtensionPackage,
|
||||||
|
): String {
|
||||||
|
val extPackage = fetchPackage()
|
||||||
|
val metadata = extPackage.metadata
|
||||||
|
val pkgName = metadata.packageName
|
||||||
|
|
||||||
|
val isInstalled =
|
||||||
|
transaction {
|
||||||
|
ExtensionTable
|
||||||
|
.select(ExtensionTable.isInstalled)
|
||||||
|
.where { ExtensionTable.pkgName eq pkgName }
|
||||||
|
.firstOrNull()
|
||||||
|
}?.get(ExtensionTable.isInstalled) ?: false
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
if (forceReinstall) {
|
||||||
|
uninstallExtension(pkgName)
|
||||||
|
} else {
|
||||||
|
extPackage.file.deleteExisting()
|
||||||
|
return pkgName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata.reqFeatures.contains(EXTENSION_FEATURE)) {
|
||||||
|
extPackage.file.deleteExisting()
|
||||||
|
throw Exception("This file is not a Tachiyomi extension")
|
||||||
|
}
|
||||||
|
|
||||||
|
val libVersion = metadata.versionName.substringBeforeLast('.').toDouble()
|
||||||
|
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||||
|
extPackage.file.deleteExisting()
|
||||||
|
throw Exception(
|
||||||
|
"Lib version is $libVersion, while only versions " +
|
||||||
|
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentWarning =
|
||||||
|
extPackage.metadata
|
||||||
|
.metaData
|
||||||
|
.getInt(METADATA_CONTENT_WARNING)
|
||||||
|
if (contentWarning == 0) {
|
||||||
|
contentWarning = extPackage.metadata
|
||||||
|
.metaData
|
||||||
|
.getString(METADATA_CONTENT_WARNING)
|
||||||
|
?.toIntOrNull() ?: 0
|
||||||
|
if (contentWarning == 0) {
|
||||||
|
contentWarning = extPackage.metadata
|
||||||
|
.metaData
|
||||||
|
.getString(METADATA_NSFW)
|
||||||
|
?.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sourceClass =
|
||||||
|
metadata.metaData
|
||||||
|
.getString(METADATA_SOURCE_CLASS)!!
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
val className =
|
||||||
|
if (sourceClass.startsWith(".")) {
|
||||||
|
pkgName + sourceClass
|
||||||
|
} else {
|
||||||
|
sourceClass
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug { "Main class for extension is $className" }
|
||||||
|
|
||||||
|
val extensionsRoot = Path(applicationDirs.extensionsRoot)
|
||||||
|
val jarFile = extPackage.prepareJarAndIcons(extensionsRoot)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val extensionName =
|
||||||
|
metadata.metaData.getString(METADATA_NAME)
|
||||||
|
?: metadata.label?.substringAfter("Tachiyomi: ")
|
||||||
|
?: throw Exception("Could not resolve extension name")
|
||||||
|
|
||||||
|
val extensionLibVersion =
|
||||||
|
metadata.metaData
|
||||||
|
.getString(METADATA_EXTENSION_LIB)
|
||||||
|
.takeUnless { it == "0" }
|
||||||
|
?: metadata.versionName.substringBeforeLast('.')
|
||||||
|
|
||||||
|
val apkName =
|
||||||
|
when (extPackage) {
|
||||||
|
is ExtensionPackage.Apk -> extPackage.file.name
|
||||||
|
is ExtensionPackage.Jar -> jarFile.name.removeSuffix(".jar") + ".apk"
|
||||||
|
}
|
||||||
|
|
||||||
|
setupJar(
|
||||||
|
jarFile = jarFile,
|
||||||
|
className = className,
|
||||||
|
extensionName = extensionName,
|
||||||
|
extensionLibVersion = extensionLibVersion,
|
||||||
|
apkName = apkName,
|
||||||
|
pkgName = pkgName,
|
||||||
|
versionName = metadata.versionName,
|
||||||
|
versionCode = metadata.versionCode,
|
||||||
|
contentWarning = contentWarning,
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// free up the file descriptor if exists
|
||||||
|
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
|
||||||
|
jarFile.deleteIfExists()
|
||||||
|
|
||||||
try {
|
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)
|
||||||
throw e
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return pkgName
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupJar(
|
||||||
|
jarFile: Path,
|
||||||
|
className: String,
|
||||||
|
extensionName: String,
|
||||||
|
extensionLibVersion: String,
|
||||||
|
apkName: String,
|
||||||
|
pkgName: String,
|
||||||
|
versionName: String,
|
||||||
|
versionCode: Int,
|
||||||
|
contentWarning: Int,
|
||||||
|
) {
|
||||||
|
// collect sources from the extension
|
||||||
|
val extensionMainClassInstance = loadExtensionSources(jarFile, className)
|
||||||
|
val sources: List<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(
|
||||||
apkFilePath: String,
|
apkFile: Path,
|
||||||
pkgName: String,
|
pkgName: String,
|
||||||
) {
|
) {
|
||||||
val iconCacheDir = "${applicationDirs.extensionsRoot}/icon"
|
|
||||||
try {
|
try {
|
||||||
val iconData =
|
val iconData =
|
||||||
ApkFile(File(apkFilePath)).use { apk ->
|
ApkFile(apkFile.toFile()).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 } }
|
||||||
@@ -290,31 +474,36 @@ 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 extractAssetsFromApk(
|
private fun cacheIcon(
|
||||||
apkPath: String,
|
pkgName: String,
|
||||||
jarPath: String,
|
inputStream: InputStream,
|
||||||
) {
|
) {
|
||||||
val apkFile = File(apkPath)
|
val iconCacheDir = Path(applicationDirs.extensionsRoot) / "icon"
|
||||||
val jarFile = File(jarPath)
|
iconCacheDir.createDirectories()
|
||||||
|
clearCachedImage(iconCacheDir.absolutePathString(), pkgName)
|
||||||
|
saveImage("$iconCacheDir/$pkgName", inputStream, null)
|
||||||
|
}
|
||||||
|
|
||||||
val assetsFolder = File("${apkFile.parent}/${apkFile.nameWithoutExtension}_assets")
|
@OptIn(ExperimentalPathApi::class)
|
||||||
assetsFolder.mkdir()
|
private fun extractAssetsFromApk(
|
||||||
|
apkFile: Path,
|
||||||
|
jarFile: Path,
|
||||||
|
) {
|
||||||
|
val assetsFolder = apkFile.parent / "${apkFile.nameWithoutExtension}_assets"
|
||||||
|
assetsFolder.createDirectories()
|
||||||
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 = File(assetsFolder, zipEntry.name)
|
val assetFile = assetsFolder / zipEntry.name
|
||||||
assetFile.parentFile.mkdirs()
|
assetFile.createParentDirectories()
|
||||||
FileOutputStream(assetFile).use { outputStream ->
|
assetFile.outputStream().use { outputStream ->
|
||||||
zipInputStream.copyTo(outputStream)
|
zipInputStream.copyTo(outputStream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,9 +511,9 @@ object Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val tempJarFile = File("${jarFile.parent}/${jarFile.nameWithoutExtension}_temp.jar")
|
val tempJarFile = jarFile.parent / "${jarFile.nameWithoutExtension}_temp.jar"
|
||||||
ZipInputStream(jarFile.inputStream()).use { jarZipInputStream ->
|
ZipInputStream(jarFile.inputStream()).use { jarZipInputStream ->
|
||||||
ZipOutputStream(FileOutputStream(tempJarFile)).use { jarZipOutputStream ->
|
ZipOutputStream(tempJarFile.outputStream()).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/")) {
|
||||||
@@ -333,8 +522,8 @@ object Extension {
|
|||||||
}
|
}
|
||||||
zipEntry = jarZipInputStream.nextEntry
|
zipEntry = jarZipInputStream.nextEntry
|
||||||
}
|
}
|
||||||
assetsFolder.walkTopDown().forEach { file ->
|
assetsFolder.walk().forEach { file ->
|
||||||
if (file.isFile) {
|
if (file.isRegularFile()) {
|
||||||
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)
|
||||||
@@ -345,17 +534,25 @@ object Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jarFile.delete()
|
jarFile.deleteIfExists()
|
||||||
tempJarFile.renameTo(jarFile)
|
tempJarFile.copyTo(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 downloadAPKFile(
|
private suspend fun downloadExtension(
|
||||||
url: String,
|
url: String,
|
||||||
savePath: String,
|
savePath: Path,
|
||||||
) {
|
) {
|
||||||
val response =
|
val response =
|
||||||
network.client
|
network.client
|
||||||
@@ -363,11 +560,10 @@ object Extension {
|
|||||||
GET(url, cache = CacheControl.FORCE_NETWORK),
|
GET(url, cache = CacheControl.FORCE_NETWORK),
|
||||||
).await()
|
).await()
|
||||||
|
|
||||||
val downloadedFile = File(savePath)
|
savePath.createParentDirectories()
|
||||||
downloadedFile.sink().buffer().use { sink ->
|
response.body.byteStream().use {
|
||||||
response.body.source().use { source ->
|
savePath.outputStream().buffered().use { out ->
|
||||||
sink.writeAll(source)
|
it.copyTo(out)
|
||||||
sink.flush()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,7 +575,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 = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
val jarPath = Path(applicationDirs.extensionsRoot) / "$fileNameWithoutType.jar"
|
||||||
val sources =
|
val sources =
|
||||||
transaction {
|
transaction {
|
||||||
val extensionId = extensionRecord[ExtensionTable.id].value
|
val extensionId = extensionRecord[ExtensionTable.id].value
|
||||||
@@ -401,18 +597,18 @@ object Extension {
|
|||||||
sources
|
sources
|
||||||
}
|
}
|
||||||
|
|
||||||
if (File(jarPath).exists()) {
|
if (jarPath.exists()) {
|
||||||
// free up the file descriptor if exists
|
// free up the file descriptor if exists
|
||||||
PackageTools.jarLoaderMap.remove(jarPath)?.close()
|
PackageTools.jarLoaderMap.remove(jarPath.absolutePathString())?.close()
|
||||||
|
|
||||||
// clear all loaded sources
|
// clear all loaded sources
|
||||||
sources.forEach { GetSource.unregisterSource(it) }
|
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)!!
|
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
|
||||||
uninstallExtension(pkgName)
|
uninstallExtension(pkgName)
|
||||||
transaction {
|
transaction {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ 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]
|
||||||
@@ -176,6 +177,7 @@ 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()
|
||||||
@@ -193,6 +195,7 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ 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
|
||||||
@@ -109,6 +111,7 @@ 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,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ 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,
|
||||||
|
|||||||
@@ -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 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 {
|
||||||
@@ -52,15 +53,13 @@ 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: String,
|
dexFile: Path,
|
||||||
jarFile: String,
|
jarFile: Path,
|
||||||
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 jarFilePath = File(jarFile).toPath()
|
val reader = MultiDexFileReader.open(dexFile.readBytes())
|
||||||
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
|
|
||||||
val handler = BaksmaliBaseDexExceptionHandler()
|
val handler = BaksmaliBaseDexExceptionHandler()
|
||||||
Dex2jar
|
Dex2jar
|
||||||
.from(reader)
|
.from(reader)
|
||||||
@@ -73,10 +72,10 @@ object PackageTools {
|
|||||||
.noCode(false)
|
.noCode(false)
|
||||||
.skipExceptions(false)
|
.skipExceptions(false)
|
||||||
.dontSanitizeNames(true)
|
.dontSanitizeNames(true)
|
||||||
.to(jarFilePath)
|
.to(jarFile)
|
||||||
if (handler.hasException()) {
|
if (handler.hasException()) {
|
||||||
val rootPath = Path(applicationDirs.extensionsRoot)
|
val rootPath = Path(applicationDirs.extensionsRoot)
|
||||||
val errorFile: Path = rootPath.resolve("$fileNameWithoutType-error.txt")
|
val errorFile: Path = rootPath.resolve("${dexFile.nameWithoutExtension}-error.txt")
|
||||||
logger.error {
|
logger.error {
|
||||||
"""
|
"""
|
||||||
Detail Error Information in File ${errorFile.relativeTo(rootPath)}
|
Detail Error Information in File ${errorFile.relativeTo(rootPath)}
|
||||||
@@ -89,56 +88,55 @@ object PackageTools {
|
|||||||
}
|
}
|
||||||
handler.dump(errorFile, emptyArray<String>())
|
handler.dump(errorFile, emptyArray<String>())
|
||||||
} else {
|
} else {
|
||||||
BytecodeEditor.fixAndroidClasses(jarFilePath)
|
BytecodeEditor.fixAndroidClasses(jarFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
|
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
|
||||||
fun getPackageInfo(apkFilePath: String): PackageInfo {
|
fun getPackageInfo(apkFile: Path): PackageInfo =
|
||||||
val apk = File(apkFilePath)
|
ApkParsers.getMetaInfo(apkFile.toFile()).toPackageInfo(apkFile.toFile()).apply {
|
||||||
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
|
ApkFile(apkFile.toFile()).use { parsed ->
|
||||||
val parsed = ApkFile(apk)
|
val dbFactory = DocumentBuilderFactory.newInstance()
|
||||||
val dbFactory = DocumentBuilderFactory.newInstance()
|
val dBuilder = dbFactory.newDocumentBuilder()
|
||||||
val dBuilder = dbFactory.newDocumentBuilder()
|
val doc =
|
||||||
val doc =
|
parsed.manifestXml.byteInputStream().use {
|
||||||
parsed.manifestXml.byteInputStream().use {
|
dBuilder.parse(it)
|
||||||
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
|
||||||
@@ -156,19 +154,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(
|
||||||
jarPath: String,
|
jar: Path,
|
||||||
className: String,
|
className: String,
|
||||||
): Any {
|
): Any {
|
||||||
try {
|
try {
|
||||||
logger.debug { "loading jar with path: $jarPath" }
|
logger.debug { "loading jar with path: ${jar.absolutePathString()}" }
|
||||||
val classLoader = jarLoaderMap[jarPath] ?: ChildFirstURLClassLoader(arrayOf<URL>(Path(jarPath).toUri().toURL()))
|
val classLoader = jarLoaderMap[jar.absolutePathString()] ?: ChildFirstURLClassLoader(arrayOf<URL>(jar.toUri().toURL()))
|
||||||
val classToLoad = Class.forName(className, false, classLoader)
|
val classToLoad = Class.forName(className, false, classLoader)
|
||||||
|
|
||||||
jarLoaderMap[jarPath] = classLoader
|
jarLoaderMap[jar.absolutePathString()] = classLoader
|
||||||
|
|
||||||
return classToLoad.getDeclaredConstructor().newInstance()
|
return classToLoad.getDeclaredConstructor().newInstance()
|
||||||
} catch (e: Exception) {
|
} 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
|
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 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 { }
|
||||||
@@ -49,7 +51,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 = "${applicationDirs.extensionsRoot}/$jarName"
|
val jarPath = Path(applicationDirs.extensionsRoot) / jarName
|
||||||
|
|
||||||
when (val instance = loadExtensionSources(jarPath, className)) {
|
when (val instance = loadExtensionSources(jarPath, className)) {
|
||||||
is Source -> listOf(instance)
|
is Source -> listOf(instance)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -27,6 +27,7 @@ import suwayomi.tachidesk.manga.impl.Source.getSourceList
|
|||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
|
import suwayomi.tachidesk.manga.impl.extension.Extension.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
|
||||||
@@ -50,6 +51,8 @@ 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
|
||||||
@@ -57,6 +60,14 @@ 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 {
|
||||||
@@ -72,7 +83,10 @@ class TestExtensionCompatibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
uninstallExtension(it.pkgName)
|
try {
|
||||||
|
uninstallExtension(it.pkgName)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
installExtension(it.pkgName)
|
installExtension(it.pkgName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,9 +149,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.mangaDetailsRequest(
|
"Failed to fetch manga info and chapters from $source for ${manga.title} (${source.getMangaUrl(
|
||||||
manga,
|
manga,
|
||||||
).url}): ${e.message}"
|
)}): ${e.message}"
|
||||||
}
|
}
|
||||||
mangaFailedToFetch += Triple(source, manga, e)
|
mangaFailedToFetch += Triple(source, manga, e)
|
||||||
}
|
}
|
||||||
@@ -147,7 +161,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.mangaDetailsRequest(manga).url}):" +
|
" ${manga.title} (${source.getMangaUrl(manga)}):" +
|
||||||
" ${exception.message}"
|
" ${exception.message}"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -163,9 +177,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.mangaDetailsRequest(
|
"Failed to fetch manga info from $source for ${manga.title} (${source.getMangaUrl(
|
||||||
manga,
|
manga,
|
||||||
).url}): ${e.message}"
|
)}): ${e.message}"
|
||||||
}
|
}
|
||||||
chaptersPageListFailedToFetch += Triple(source, manga to chapter, e)
|
chaptersPageListFailedToFetch += Triple(source, manga to chapter, e)
|
||||||
}
|
}
|
||||||
@@ -176,7 +190,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.mangaDetailsRequest(manga.first).url}):" +
|
" ${manga.first.title} (${source.getMangaUrl(manga.first)}):" +
|
||||||
" ${manga.second.name} (${manga.second.url}): ${exception.message}"
|
" ${manga.second.name} (${manga.second.url}): ${exception.message}"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user