mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-13 15:54:35 -05:00
Compare commits
3 Commits
renovate/a
...
master
| 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
|
||||
description: You can find your Suwayomi-Server version in **More → About**.
|
||||
placeholder: |
|
||||
Example: "v2.3.2238"
|
||||
Example: "v2.3.2243"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
6
.github/workflows/build_pull_request.yml
vendored
6
.github/workflows/build_pull_request.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v5
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout pull request
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: master
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Validate all options are documented
|
||||
run: |
|
||||
|
||||
6
.github/workflows/build_push.yml
vendored
6
.github/workflows/build_push.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v5
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout master branch
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: master
|
||||
path: master
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
path: release
|
||||
|
||||
- name: Checkout Preview branch
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: "Suwayomi/Suwayomi-Server-preview"
|
||||
ref: main
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v5
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout ${{ github.ref }}
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
path: master
|
||||
|
||||
4
.github/workflows/wiki.yml
vendored
4
.github/workflows/wiki.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{github.repository}}
|
||||
path: ${{github.repository}}
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
fetch-tags: true
|
||||
|
||||
- name: Checkout Wiki
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{github.repository}}.wiki
|
||||
path: ${{github.repository}}.wiki
|
||||
|
||||
2
.github/workflows/winget.yml
vendored
2
.github/workflows/winget.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
publish:
|
||||
runs-on: windows-latest # action can only be run on windows
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Determine version
|
||||
id: version
|
||||
|
||||
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
|
||||
- .
|
||||
|
||||
### Fixed
|
||||
- .
|
||||
|
||||
## [v2.3.2243] - 2026-07-13
|
||||
|
||||
### Added
|
||||
- (**Extension**) Add ability to install jar versions of extensions
|
||||
|
||||
### Fixed
|
||||
- (**Extension**) Fix missing memo field in getPageList
|
||||
|
||||
@@ -500,7 +508,8 @@ Huge thanks to @martinek who pulled the most of the weight this release!
|
||||
|
||||
<!-- 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.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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
@@ -17,9 +18,7 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import net.dongliu.apk.parser.ApkFile
|
||||
import net.dongliu.apk.parser.bean.Icon
|
||||
import okhttp3.CacheControl
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
@@ -27,6 +26,7 @@ import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import suwayomi.tachidesk.manga.impl.util.AndroidManifestParser
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
||||
@@ -39,6 +39,7 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
|
||||
import suwayomi.tachidesk.manga.impl.util.ResourceArscIconParser
|
||||
import suwayomi.tachidesk.manga.impl.util.network.await
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
|
||||
@@ -48,148 +49,356 @@ import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.nio.file.Path
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.io.path.ExperimentalPathApi
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.absolutePathString
|
||||
import kotlin.io.path.copyTo
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.createParentDirectories
|
||||
import kotlin.io.path.deleteExisting
|
||||
import kotlin.io.path.deleteIfExists
|
||||
import kotlin.io.path.deleteRecursively
|
||||
import kotlin.io.path.div
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.inputStream
|
||||
import kotlin.io.path.isRegularFile
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.nameWithoutExtension
|
||||
import kotlin.io.path.outputStream
|
||||
import kotlin.io.path.relativeTo
|
||||
import kotlin.io.path.walk
|
||||
|
||||
object Extension {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val applicationDirs: ApplicationDirs by injectLazy()
|
||||
|
||||
suspend fun installExtension(pkgName: String): Int {
|
||||
private suspend fun fetchExtensionFile(url: String): Path {
|
||||
val name = Uri.parse(url).lastPathSegment!!
|
||||
val savePath = Path(applicationDirs.tempRoot) / "extensions" / name
|
||||
// download jar file
|
||||
downloadExtension(url, savePath)
|
||||
|
||||
return savePath
|
||||
}
|
||||
|
||||
suspend fun installExtension(pkgName: String): String {
|
||||
logger.debug { "Installing $pkgName" }
|
||||
val apkUrl =
|
||||
val extension =
|
||||
transaction {
|
||||
ExtensionTable
|
||||
.select(ExtensionTable.apkUrl)
|
||||
.select(ExtensionTable.apkUrl, ExtensionTable.jarUrl)
|
||||
.where { ExtensionTable.pkgName eq pkgName }
|
||||
.firstOrNull()
|
||||
?.get(ExtensionTable.apkUrl)
|
||||
} ?: throw NullPointerException("Could not find extension $pkgName")
|
||||
} ?: throw NullPointerException("Could not find extension for $pkgName")
|
||||
val jarUrl = extension[ExtensionTable.jarUrl]
|
||||
val apkUrl = extension[ExtensionTable.apkUrl]
|
||||
|
||||
return installAPK {
|
||||
val apkName = Uri.parse(apkUrl).lastPathSegment!!
|
||||
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
|
||||
// download apk file
|
||||
downloadAPKFile(apkUrl, apkSavePath)
|
||||
|
||||
apkSavePath
|
||||
return when {
|
||||
jarUrl != null -> {
|
||||
installExtension {
|
||||
val jar = fetchExtensionFile(jarUrl)
|
||||
val manifest = extractAndParseAndroidManifest(jar)
|
||||
ExtensionPackage.Jar(jar, manifest)
|
||||
}
|
||||
}
|
||||
|
||||
apkUrl != null -> {
|
||||
installExtension {
|
||||
val apk = fetchExtensionFile(apkUrl)
|
||||
val packageInfo = getPackageInfo(apk)
|
||||
ExtensionPackage.Apk(apk, packageInfo)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw NullPointerException("Could not find extension url for $pkgName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToExtensionsRoot(
|
||||
inputStream: InputStream,
|
||||
extensionName: String,
|
||||
): Path {
|
||||
val rootPath = Path(applicationDirs.tempRoot) / "extensions"
|
||||
val downloadedFile = rootPath.resolve(extensionName).normalize()
|
||||
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
|
||||
"File '$extensionName' is not a valid extension file"
|
||||
}
|
||||
logger.debug { "Saving jar at $extensionName" }
|
||||
// download jar file
|
||||
downloadedFile.createParentDirectories()
|
||||
downloadedFile.outputStream().buffered().use { out ->
|
||||
inputStream.use {
|
||||
it.copyTo(out)
|
||||
}
|
||||
}
|
||||
return downloadedFile
|
||||
}
|
||||
|
||||
suspend fun installExternalExtension(
|
||||
inputStream: InputStream,
|
||||
apkName: String,
|
||||
): Int =
|
||||
installAPK(true) {
|
||||
val rootPath = Path(applicationDirs.extensionsRoot)
|
||||
val downloadedFile = rootPath.resolve(apkName).normalize()
|
||||
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
|
||||
"File '$apkName' is not a valid extension file"
|
||||
}
|
||||
logger.debug { "Saving apk at $apkName" }
|
||||
// download apk file
|
||||
downloadedFile.outputStream().sink().buffer().use { sink ->
|
||||
inputStream.source().use { source ->
|
||||
sink.writeAll(source)
|
||||
sink.flush()
|
||||
}
|
||||
}
|
||||
downloadedFile.absolutePathString()
|
||||
extensionName: String,
|
||||
): String =
|
||||
when {
|
||||
extensionName.endsWith(".jar") -> {
|
||||
installExtension(
|
||||
true,
|
||||
{
|
||||
val jar = copyToExtensionsRoot(inputStream, extensionName)
|
||||
val manifest = extractAndParseAndroidManifest(jar)
|
||||
ExtensionPackage.Jar(jar, manifest)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun installAPK(
|
||||
extensionName.endsWith(".apk") -> {
|
||||
installExtension(
|
||||
true,
|
||||
{
|
||||
val apk = copyToExtensionsRoot(inputStream, extensionName)
|
||||
val packageInfo = getPackageInfo(apk)
|
||||
ExtensionPackage.Apk(apk, packageInfo)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw NullPointerException("Could not find extension type for $extensionName")
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ExtensionPackage {
|
||||
abstract val file: Path
|
||||
abstract val metadata: PackageMetadata
|
||||
|
||||
// Abstract hook for type-specific preprocessing
|
||||
abstract suspend fun prepareJarAndIcons(extensionsRoot: Path): Path
|
||||
|
||||
class Apk(
|
||||
override val file: Path,
|
||||
val packageInfo: PackageInfo,
|
||||
) : ExtensionPackage() {
|
||||
override val metadata =
|
||||
PackageMetadata(
|
||||
packageName = packageInfo.packageName,
|
||||
versionName = packageInfo.versionName,
|
||||
versionCode = packageInfo.versionCode,
|
||||
reqFeatures = packageInfo.reqFeatures.orEmpty().map { it.name },
|
||||
metaData = MetadataProvider.FromPackageInfo(packageInfo.applicationInfo.metaData),
|
||||
label = packageInfo.applicationInfo.nonLocalizedLabel?.toString(),
|
||||
)
|
||||
|
||||
override suspend fun prepareJarAndIcons(extensionsRoot: Path): Path {
|
||||
val jarFile = extensionsRoot / (file.nameWithoutExtension + ".jar")
|
||||
dex2jar(file, jarFile)
|
||||
extractAssetsFromApk(file, jarFile)
|
||||
extractAndCacheApkIcon(file, metadata.packageName)
|
||||
file.deleteExisting()
|
||||
return jarFile
|
||||
}
|
||||
}
|
||||
|
||||
class Jar(
|
||||
override val file: Path,
|
||||
val manifest: AndroidManifestParser.AndroidManifest,
|
||||
) : ExtensionPackage() {
|
||||
override val metadata =
|
||||
PackageMetadata(
|
||||
packageName = manifest.packageName,
|
||||
versionName = manifest.versionName!!,
|
||||
versionCode = manifest.versionCode!!,
|
||||
reqFeatures = manifest.usesFeatures.mapNotNull { it.name },
|
||||
metaData = MetadataProvider.FromManifest(manifest.application!!.metaData),
|
||||
label = manifest.application.label,
|
||||
)
|
||||
|
||||
override suspend fun prepareJarAndIcons(extensionsRoot: Path): Path {
|
||||
val jarFile = extensionsRoot / file.name
|
||||
|
||||
ZipFile.builder().setPath(file).get().use { jarZip ->
|
||||
try {
|
||||
cacheIcon(
|
||||
metadata.packageName,
|
||||
ResourceArscIconParser.extractIcon(jarZip),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Failed to extract icon from JAR ${metadata.packageName}" }
|
||||
}
|
||||
}
|
||||
|
||||
file.copyTo(jarFile)
|
||||
file.deleteExisting()
|
||||
return jarFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PackageMetadata(
|
||||
val packageName: String,
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val reqFeatures: List<String>,
|
||||
val metaData: MetadataProvider,
|
||||
val label: String?,
|
||||
)
|
||||
|
||||
sealed interface MetadataProvider {
|
||||
fun getString(key: String): String?
|
||||
|
||||
fun getInt(key: String): Int
|
||||
|
||||
class FromPackageInfo(
|
||||
private val bundle: android.os.Bundle,
|
||||
) : MetadataProvider {
|
||||
override fun getString(key: String): String? = bundle.getString(key)
|
||||
|
||||
override fun getInt(key: String): Int = bundle.getInt(key) ?: 0
|
||||
}
|
||||
|
||||
class FromManifest(
|
||||
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,
|
||||
fetcher: suspend () -> String,
|
||||
): Int {
|
||||
val apkFilePath = fetcher()
|
||||
val apkName = File(apkFilePath).name
|
||||
fetchPackage: suspend () -> ExtensionPackage,
|
||||
): String {
|
||||
val extPackage = fetchPackage()
|
||||
val metadata = extPackage.metadata
|
||||
val pkgName = metadata.packageName
|
||||
|
||||
// check if we don't have the extension already installed
|
||||
// if it's installed and we want to update, it first has to be uninstalled
|
||||
val isInstalled =
|
||||
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 dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
|
||||
val jarFilePath = "$dirPathWithoutType.jar"
|
||||
|
||||
val packageInfo = getPackageInfo(apkFilePath)
|
||||
val pkgName = packageInfo.packageName
|
||||
if (isInstalled && forceReinstall) {
|
||||
if (isInstalled) {
|
||||
if (forceReinstall) {
|
||||
uninstallExtension(pkgName)
|
||||
} else {
|
||||
extPackage.file.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 (!metadata.reqFeatures.contains(EXTENSION_FEATURE)) {
|
||||
extPackage.file.deleteExisting()
|
||||
throw Exception("This file is not a Tachiyomi extension")
|
||||
}
|
||||
|
||||
// Validate lib version
|
||||
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: allow trusting keys
|
||||
// val signatureHash = getSignatureHash(packageInfo)
|
||||
|
||||
// if (signatureHash == null) {
|
||||
// throw Exception("Package $pkgName isn't signed")
|
||||
// } else if (signatureHash !in trustedSignatures) {
|
||||
// throw Exception("This apk is not a signed with the official tachiyomi signature")
|
||||
// }
|
||||
|
||||
var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING)
|
||||
var contentWarning =
|
||||
extPackage.metadata
|
||||
.metaData
|
||||
.getInt(METADATA_CONTENT_WARNING)
|
||||
if (contentWarning == 0) {
|
||||
contentWarning = packageInfo.applicationInfo.metaData
|
||||
contentWarning = extPackage.metadata
|
||||
.metaData
|
||||
.getString(METADATA_CONTENT_WARNING)
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
?.toIntOrNull() ?: 0
|
||||
if (contentWarning == 0) {
|
||||
contentWarning = packageInfo.applicationInfo.metaData
|
||||
contentWarning = extPackage.metadata
|
||||
.metaData
|
||||
.getString(METADATA_NSFW)
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
?.toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
|
||||
val sourceClass =
|
||||
packageInfo.applicationInfo.metaData
|
||||
metadata.metaData
|
||||
.getString(METADATA_SOURCE_CLASS)!!
|
||||
.trim()
|
||||
|
||||
val className =
|
||||
if (sourceClass.startsWith(".")) {
|
||||
packageInfo.packageName + sourceClass
|
||||
pkgName + 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()
|
||||
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 {
|
||||
uninstallExtension(pkgName)
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
throw e
|
||||
}
|
||||
return pkgName
|
||||
}
|
||||
|
||||
private fun setupJar(
|
||||
jarFile: Path,
|
||||
className: String,
|
||||
extensionName: String,
|
||||
extensionLibVersion: String,
|
||||
apkName: String,
|
||||
pkgName: String,
|
||||
versionName: String,
|
||||
versionCode: Int,
|
||||
contentWarning: Int,
|
||||
) {
|
||||
// collect sources from the extension
|
||||
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className)
|
||||
val extensionMainClassInstance = loadExtensionSources(jarFile, className)
|
||||
val sources: List<Source> =
|
||||
when (extensionMainClassInstance) {
|
||||
is Source -> listOf(extensionMainClassInstance)
|
||||
@@ -205,27 +414,15 @@ object Extension {
|
||||
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[this.pkgName] = pkgName
|
||||
it[this.versionName] = versionName
|
||||
it[this.versionCode] = versionCode.toLong()
|
||||
it[extensionLib] = extensionLibVersion
|
||||
it[lang] = extensionLang
|
||||
it[this.contentWarning] = contentWarning
|
||||
@@ -236,8 +433,8 @@ object Extension {
|
||||
it[this.apkName] = apkName
|
||||
it[this.isInstalled] = true
|
||||
it[this.classFQName] = className
|
||||
it[versionName] = packageInfo.versionName
|
||||
it[versionCode] = packageInfo.versionCode.toLong()
|
||||
it[this.versionName] = versionName
|
||||
it[this.versionCode] = versionCode.toLong()
|
||||
}
|
||||
|
||||
val extensionId =
|
||||
@@ -258,28 +455,15 @@ object Extension {
|
||||
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)
|
||||
throw e
|
||||
}
|
||||
} 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 +474,36 @@ object Extension {
|
||||
logger.warn { "No icon found in APK $pkgName" }
|
||||
return
|
||||
}
|
||||
|
||||
File(iconCacheDir).mkdirs()
|
||||
clearCachedImage(iconCacheDir, pkgName)
|
||||
saveImage("$iconCacheDir/$pkgName", iconData.inputStream(), null)
|
||||
cacheIcon(pkgName, iconData.inputStream())
|
||||
} catch (e: Exception) {
|
||||
logger.warn(e) { "Failed to extract icon from APK $pkgName" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractAssetsFromApk(
|
||||
apkPath: String,
|
||||
jarPath: String,
|
||||
private fun cacheIcon(
|
||||
pkgName: String,
|
||||
inputStream: InputStream,
|
||||
) {
|
||||
val apkFile = File(apkPath)
|
||||
val jarFile = File(jarPath)
|
||||
val iconCacheDir = Path(applicationDirs.extensionsRoot) / "icon"
|
||||
iconCacheDir.createDirectories()
|
||||
clearCachedImage(iconCacheDir.absolutePathString(), pkgName)
|
||||
saveImage("$iconCacheDir/$pkgName", inputStream, null)
|
||||
}
|
||||
|
||||
val assetsFolder = File("${apkFile.parent}/${apkFile.nameWithoutExtension}_assets")
|
||||
assetsFolder.mkdir()
|
||||
@OptIn(ExperimentalPathApi::class)
|
||||
private fun extractAssetsFromApk(
|
||||
apkFile: Path,
|
||||
jarFile: Path,
|
||||
) {
|
||||
val assetsFolder = apkFile.parent / "${apkFile.nameWithoutExtension}_assets"
|
||||
assetsFolder.createDirectories()
|
||||
ZipInputStream(apkFile.inputStream()).use { zipInputStream ->
|
||||
var zipEntry = zipInputStream.nextEntry
|
||||
while (zipEntry != null) {
|
||||
if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) {
|
||||
val assetFile = File(assetsFolder, zipEntry.name)
|
||||
assetFile.parentFile.mkdirs()
|
||||
FileOutputStream(assetFile).use { outputStream ->
|
||||
val assetFile = assetsFolder / zipEntry.name
|
||||
assetFile.createParentDirectories()
|
||||
assetFile.outputStream().use { outputStream ->
|
||||
zipInputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
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 +522,8 @@ object Extension {
|
||||
}
|
||||
zipEntry = jarZipInputStream.nextEntry
|
||||
}
|
||||
assetsFolder.walkTopDown().forEach { file ->
|
||||
if (file.isFile) {
|
||||
assetsFolder.walk().forEach { file ->
|
||||
if (file.isRegularFile()) {
|
||||
jarZipOutputStream.putNextEntry(ZipEntry(file.relativeTo(assetsFolder).toString().replace("\\", "/")))
|
||||
file.inputStream().use { inputStream ->
|
||||
inputStream.copyTo(jarZipOutputStream)
|
||||
@@ -345,17 +534,25 @@ object Extension {
|
||||
}
|
||||
}
|
||||
|
||||
jarFile.delete()
|
||||
tempJarFile.renameTo(jarFile)
|
||||
jarFile.deleteIfExists()
|
||||
tempJarFile.copyTo(jarFile)
|
||||
tempJarFile.deleteExisting()
|
||||
|
||||
assetsFolder.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun extractAndParseAndroidManifest(jar: Path): AndroidManifestParser.AndroidManifest =
|
||||
ZipFile.builder().setPath(jar).get().use { jarZip ->
|
||||
jarZip.getInputStream(jarZip.getEntry("AndroidManifest.xml")).use {
|
||||
AndroidManifestParser.parse(it)
|
||||
}
|
||||
}
|
||||
|
||||
private val network: NetworkHelper by injectLazy()
|
||||
|
||||
private suspend fun downloadAPKFile(
|
||||
private suspend fun downloadExtension(
|
||||
url: String,
|
||||
savePath: String,
|
||||
savePath: Path,
|
||||
) {
|
||||
val response =
|
||||
network.client
|
||||
@@ -363,11 +560,10 @@ object Extension {
|
||||
GET(url, cache = CacheControl.FORCE_NETWORK),
|
||||
).await()
|
||||
|
||||
val downloadedFile = File(savePath)
|
||||
downloadedFile.sink().buffer().use { sink ->
|
||||
response.body.source().use { source ->
|
||||
sink.writeAll(source)
|
||||
sink.flush()
|
||||
savePath.createParentDirectories()
|
||||
response.body.byteStream().use {
|
||||
savePath.outputStream().buffered().use { out ->
|
||||
it.copyTo(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,7 +575,7 @@ object Extension {
|
||||
val fileNameWithoutType =
|
||||
extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk")
|
||||
?: throw NullPointerException("Missing $pkgName apkName")
|
||||
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
||||
val jarPath = Path(applicationDirs.extensionsRoot) / "$fileNameWithoutType.jar"
|
||||
val sources =
|
||||
transaction {
|
||||
val extensionId = extensionRecord[ExtensionTable.id].value
|
||||
@@ -401,18 +597,18 @@ object Extension {
|
||||
sources
|
||||
}
|
||||
|
||||
if (File(jarPath).exists()) {
|
||||
if (jarPath.exists()) {
|
||||
// free up the file descriptor if exists
|
||||
PackageTools.jarLoaderMap.remove(jarPath)?.close()
|
||||
PackageTools.jarLoaderMap.remove(jarPath.absolutePathString())?.close()
|
||||
|
||||
// clear all loaded sources
|
||||
sources.forEach { GetSource.unregisterSource(it) }
|
||||
|
||||
File(jarPath).delete()
|
||||
jarPath.deleteExisting()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateExtension(pkgName: String): Int {
|
||||
suspend fun updateExtension(pkgName: String): String {
|
||||
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
|
||||
uninstallExtension(pkgName)
|
||||
transaction {
|
||||
|
||||
@@ -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 {
|
||||
ApkFile(apkFile.toFile()).use { parsed ->
|
||||
val dbFactory = DocumentBuilderFactory.newInstance()
|
||||
val dBuilder = dbFactory.newDocumentBuilder()
|
||||
val doc =
|
||||
@@ -156,19 +154,19 @@ object PackageTools {
|
||||
* It may return an instance of HttpSource or SourceFactory depending on the extension.
|
||||
*/
|
||||
fun loadExtensionSources(
|
||||
jarPath: String,
|
||||
jar: Path,
|
||||
className: String,
|
||||
): Any {
|
||||
try {
|
||||
logger.debug { "loading jar with path: $jarPath" }
|
||||
val classLoader = jarLoaderMap[jarPath] ?: ChildFirstURLClassLoader(arrayOf<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",
|
||||
)
|
||||
@@ -27,6 +27,7 @@ import suwayomi.tachidesk.manga.impl.Source.getSourceList
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension.updateExtension
|
||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionStoreService
|
||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.getExtensionList
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrNull
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
||||
@@ -50,6 +51,8 @@ class TestExtensionCompatibility {
|
||||
private val chaptersToFetch = mutableListOf<Triple<HttpSource, SManga, SChapter>>()
|
||||
private val chaptersPageListFailedToFetch = mutableListOf<Triple<HttpSource, Pair<SManga, SChapter>, Exception>>()
|
||||
|
||||
val repos = listOf<String>()
|
||||
|
||||
@BeforeAll
|
||||
fun setup() {
|
||||
val dataRoot = File(BASE_PATH).absolutePath
|
||||
@@ -57,6 +60,14 @@ class TestExtensionCompatibility {
|
||||
Looper.clearMainLooperForTest()
|
||||
SettingsRegistry.clear()
|
||||
applicationSetup()
|
||||
repos.forEach {
|
||||
val store =
|
||||
runBlocking {
|
||||
ExtensionStoreService.fetch(it)
|
||||
}
|
||||
ExtensionStoreService.upsert(store)
|
||||
}
|
||||
ExtensionStoreService.syncDbToPrefs()
|
||||
setLoggingEnabled(false)
|
||||
|
||||
runBlocking {
|
||||
@@ -72,7 +83,10 @@ class TestExtensionCompatibility {
|
||||
}
|
||||
|
||||
else -> {
|
||||
try {
|
||||
uninstallExtension(it.pkgName)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
installExtension(it.pkgName)
|
||||
}
|
||||
}
|
||||
@@ -135,9 +149,9 @@ class TestExtensionCompatibility {
|
||||
repeat { source.getMangaUpdate(manga, emptyList(), true, true) }
|
||||
} catch (e: Exception) {
|
||||
logger.warn {
|
||||
"Failed to fetch manga info and chapters from $source for ${manga.title} (${source.mangaDetailsRequest(
|
||||
"Failed to fetch manga info and chapters from $source for ${manga.title} (${source.getMangaUrl(
|
||||
manga,
|
||||
).url}): ${e.message}"
|
||||
)}): ${e.message}"
|
||||
}
|
||||
mangaFailedToFetch += Triple(source, manga, e)
|
||||
}
|
||||
@@ -147,7 +161,7 @@ class TestExtensionCompatibility {
|
||||
File("$BASE_PATH/MangaFailedToFetch.txt").writeText(
|
||||
mangaFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
||||
"${source.name} (${source.lang}, ${source.id}):" +
|
||||
" ${manga.title} (${source.mangaDetailsRequest(manga).url}):" +
|
||||
" ${manga.title} (${source.getMangaUrl(manga)}):" +
|
||||
" ${exception.message}"
|
||||
},
|
||||
)
|
||||
@@ -163,9 +177,9 @@ class TestExtensionCompatibility {
|
||||
repeat { source.getPageList(chapter) }
|
||||
} catch (e: Exception) {
|
||||
logger.warn {
|
||||
"Failed to fetch manga info from $source for ${manga.title} (${source.mangaDetailsRequest(
|
||||
"Failed to fetch manga info from $source for ${manga.title} (${source.getMangaUrl(
|
||||
manga,
|
||||
).url}): ${e.message}"
|
||||
)}): ${e.message}"
|
||||
}
|
||||
chaptersPageListFailedToFetch += Triple(source, manga to chapter, e)
|
||||
}
|
||||
@@ -176,7 +190,7 @@ class TestExtensionCompatibility {
|
||||
File("$BASE_PATH/ChapterPageListFailedToFetch.txt").writeText(
|
||||
chaptersPageListFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
||||
"${source.name} (${source.lang}, ${source.id}):" +
|
||||
" ${manga.first.title} (${source.mangaDetailsRequest(manga.first).url}):" +
|
||||
" ${manga.first.title} (${source.getMangaUrl(manga.first)}):" +
|
||||
" ${manga.second.name} (${manga.second.url}): ${exception.message}"
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user