mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-14 16:24:34 -05:00
Compare commits
1 Commits
v2.3.2243
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
027356eda5 |
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.2243"
|
Example: "v2.3.2238"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -15,14 +15,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
### Fixed
|
### Fixed
|
||||||
- .
|
- .
|
||||||
|
|
||||||
## [v2.3.2243] - 2026-07-13
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- (**Extension**) Add ability to install jar versions of extensions
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- (**Extension**) Fix missing memo field in getPageList
|
|
||||||
|
|
||||||
## [v2.3.2238] - 2026-07-05
|
## [v2.3.2238] - 2026-07-05
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -508,8 +500,7 @@ Huge thanks to @martinek who pulled the most of the weight this release!
|
|||||||
|
|
||||||
<!-- SERVER LINKS -->
|
<!-- SERVER LINKS -->
|
||||||
|
|
||||||
[unreleased]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2243...HEAD
|
[unreleased]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2238...HEAD
|
||||||
[v2.3.2243]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2238...v2.3.2243
|
|
||||||
[v2.3.2238]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2232...v2.3.2238
|
[v2.3.2238]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2232...v2.3.2238
|
||||||
[v2.3.2232]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2230...v2.3.2232
|
[v2.3.2232]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2230...v2.3.2232
|
||||||
[v2.3.2230]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2223...v2.3.2230
|
[v2.3.2230]: https://github.com/suwayomi/suwayomi-server/compare/v2.3.2223...v2.3.2230
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ dex2jar-tools = { module = "de.femtopedia.dex2jar:dex-tools", version.ref = "dex
|
|||||||
# APK
|
# APK
|
||||||
apk-parser = "net.dongliu:apk-parser:2.6.10"
|
apk-parser = "net.dongliu:apk-parser:2.6.10"
|
||||||
apksig = "com.android.tools.build:apksig:9.2.1"
|
apksig = "com.android.tools.build:apksig:9.2.1"
|
||||||
axml = "com.github.Aliucord:axml:ed26565eb0"
|
|
||||||
|
|
||||||
# Xml
|
# Xml
|
||||||
xmlpull = "xmlpull:xmlpull:1.1.3.4a"
|
xmlpull = "xmlpull:xmlpull:1.1.3.4a"
|
||||||
@@ -187,7 +186,7 @@ buildconfig = { id = "com.github.gmazzo.buildconfig", version = "6.0.10"}
|
|||||||
download = { id = "de.undercouch.download", version = "5.7.0"}
|
download = { id = "de.undercouch.download", version = "5.7.0"}
|
||||||
|
|
||||||
# ShadowJar
|
# ShadowJar
|
||||||
shadowjar = { id = "com.gradleup.shadow", version = "8.3.11"}
|
shadowjar = { id = "com.gradleup.shadow", version = "9.5.1"}
|
||||||
|
|
||||||
# Moko
|
# Moko
|
||||||
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
|
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
|
||||||
@@ -216,7 +215,6 @@ shared = [
|
|||||||
"dex2jar-translator",
|
"dex2jar-translator",
|
||||||
"dex2jar-tools",
|
"dex2jar-tools",
|
||||||
"apk-parser",
|
"apk-parser",
|
||||||
"axml",
|
|
||||||
"jackson-annotations",
|
"jackson-annotations",
|
||||||
"jcef",
|
"jcef",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -179,10 +179,10 @@ class ExtensionMutation {
|
|||||||
val (clientMutationId, extensionFile) = input
|
val (clientMutationId, extensionFile) = input
|
||||||
|
|
||||||
return future {
|
return future {
|
||||||
val pkgName = Extension.installExternalExtension(extensionFile.content(), extensionFile.filename())
|
Extension.installExternalExtension(extensionFile.content(), extensionFile.filename())
|
||||||
|
|
||||||
val dbExtension =
|
val dbExtension =
|
||||||
transaction { ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.first() }
|
transaction { ExtensionTable.selectAll().where { ExtensionTable.apkName eq extensionFile.filename() }.first() }
|
||||||
|
|
||||||
InstallExternalExtensionPayload(
|
InstallExternalExtensionPayload(
|
||||||
clientMutationId,
|
clientMutationId,
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ class ExtensionQuery {
|
|||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val pkgName: String? = null,
|
val pkgName: String? = null,
|
||||||
val apkUrl: String? = null,
|
val apkUrl: String? = null,
|
||||||
val jarUrl: String? = null,
|
|
||||||
val extensionLib: String? = null,
|
val extensionLib: String? = null,
|
||||||
val versionName: String? = null,
|
val versionName: String? = null,
|
||||||
val versionCode: Int? = null,
|
val versionCode: Int? = null,
|
||||||
@@ -123,7 +122,6 @@ class ExtensionQuery {
|
|||||||
opAnd.eq(apkName, ExtensionTable.apkName)
|
opAnd.eq(apkName, ExtensionTable.apkName)
|
||||||
opAnd.eq(iconUrl, ExtensionTable.iconUrl)
|
opAnd.eq(iconUrl, ExtensionTable.iconUrl)
|
||||||
opAnd.eq(apkUrl, ExtensionTable.apkUrl)
|
opAnd.eq(apkUrl, ExtensionTable.apkUrl)
|
||||||
opAnd.eq(jarUrl, ExtensionTable.jarUrl)
|
|
||||||
opAnd.eq(name, ExtensionTable.name)
|
opAnd.eq(name, ExtensionTable.name)
|
||||||
opAnd.eq(extensionLib, ExtensionTable.extensionLib)
|
opAnd.eq(extensionLib, ExtensionTable.extensionLib)
|
||||||
opAnd.eq(versionName, ExtensionTable.versionName)
|
opAnd.eq(versionName, ExtensionTable.versionName)
|
||||||
@@ -152,7 +150,6 @@ class ExtensionQuery {
|
|||||||
val name: StringFilter? = null,
|
val name: StringFilter? = null,
|
||||||
val pkgName: StringFilter? = null,
|
val pkgName: StringFilter? = null,
|
||||||
val apkUrl: StringFilter? = null,
|
val apkUrl: StringFilter? = null,
|
||||||
val jarUrl: StringFilter? = null,
|
|
||||||
val versionName: StringFilter? = null,
|
val versionName: StringFilter? = null,
|
||||||
val extensionLib: StringFilter? = null,
|
val extensionLib: StringFilter? = null,
|
||||||
@GraphQLDeprecated("", ReplaceWith("versionCodeLong"))
|
@GraphQLDeprecated("", ReplaceWith("versionCodeLong"))
|
||||||
@@ -178,7 +175,6 @@ class ExtensionQuery {
|
|||||||
andFilterWithCompareString(ExtensionTable.name, name),
|
andFilterWithCompareString(ExtensionTable.name, name),
|
||||||
andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
|
andFilterWithCompareString(ExtensionTable.pkgName, pkgName),
|
||||||
andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl),
|
andFilterWithCompareString(ExtensionTable.apkUrl, apkUrl),
|
||||||
andFilterWithCompareString(ExtensionTable.jarUrl, jarUrl),
|
|
||||||
andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib),
|
andFilterWithCompareString(ExtensionTable.extensionLib, extensionLib),
|
||||||
andFilterWithCompareString(ExtensionTable.versionName, versionName),
|
andFilterWithCompareString(ExtensionTable.versionName, versionName),
|
||||||
andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),
|
andFilterWithCompare(ExtensionTable.versionCode, versionCodeLong),
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class ExtensionType(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val pkgName: String,
|
val pkgName: String,
|
||||||
val apkUrl: String?,
|
val apkUrl: String?,
|
||||||
val jarUrl: String?,
|
|
||||||
val extensionLib: String?,
|
val extensionLib: String?,
|
||||||
val versionName: String,
|
val versionName: String,
|
||||||
@GraphQLDeprecated(
|
@GraphQLDeprecated(
|
||||||
@@ -57,7 +56,6 @@ class ExtensionType(
|
|||||||
name = row[ExtensionTable.name],
|
name = row[ExtensionTable.name],
|
||||||
pkgName = row[ExtensionTable.pkgName],
|
pkgName = row[ExtensionTable.pkgName],
|
||||||
apkUrl = row[ExtensionTable.apkUrl],
|
apkUrl = row[ExtensionTable.apkUrl],
|
||||||
jarUrl = row[ExtensionTable.jarUrl],
|
|
||||||
extensionLib = row[ExtensionTable.extensionLib],
|
extensionLib = row[ExtensionTable.extensionLib],
|
||||||
versionName = row[ExtensionTable.versionName],
|
versionName = row[ExtensionTable.versionName],
|
||||||
versionCode = row[ExtensionTable.versionCode].toInt(),
|
versionCode = row[ExtensionTable.versionCode].toInt(),
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ object ExtensionController {
|
|||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
Extension.installExtension(pkgName)
|
Extension.installExtension(pkgName)
|
||||||
|
}.thenApply {
|
||||||
|
ctx.status(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -97,6 +99,8 @@ object ExtensionController {
|
|||||||
uploadedFile.content(),
|
uploadedFile.content(),
|
||||||
uploadedFile.filename(),
|
uploadedFile.filename(),
|
||||||
)
|
)
|
||||||
|
}.thenApply {
|
||||||
|
ctx.status(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -122,6 +126,8 @@ object ExtensionController {
|
|||||||
ctx.future {
|
ctx.future {
|
||||||
future {
|
future {
|
||||||
Extension.updateExtension(pkgName)
|
Extension.updateExtension(pkgName)
|
||||||
|
}.thenApply {
|
||||||
|
ctx.status(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
|||||||
import io.github.reactivecircus.cache4k.Cache
|
import io.github.reactivecircus.cache4k.Cache
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.jetbrains.exposed.v1.core.ResultRow
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.v1.core.and
|
import org.jetbrains.exposed.v1.core.and
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
@@ -89,7 +88,6 @@ suspend fun refreshChapterPageList(
|
|||||||
scanlator = chapterEntry[ChapterTable.scanlator]
|
scanlator = chapterEntry[ChapterTable.scanlator]
|
||||||
chapter_number = chapterEntry[ChapterTable.chapter_number]
|
chapter_number = chapterEntry[ChapterTable.chapter_number]
|
||||||
date_upload = chapterEntry[ChapterTable.date_upload]
|
date_upload = chapterEntry[ChapterTable.date_upload]
|
||||||
memo = Json.decodeFromString(chapterEntry[ChapterTable.memo])
|
|
||||||
},
|
},
|
||||||
).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }
|
).mapIndexed { index, page -> Page(index, page.url, page.imageUrl, page.uri) }
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ package suwayomi.tachidesk.manga.impl.extension
|
|||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
@@ -18,7 +17,9 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
|||||||
import net.dongliu.apk.parser.ApkFile
|
import net.dongliu.apk.parser.ApkFile
|
||||||
import net.dongliu.apk.parser.bean.Icon
|
import net.dongliu.apk.parser.bean.Icon
|
||||||
import okhttp3.CacheControl
|
import okhttp3.CacheControl
|
||||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
|
import okio.source
|
||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||||
import org.jetbrains.exposed.v1.jdbc.insert
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
@@ -26,7 +27,6 @@ import org.jetbrains.exposed.v1.jdbc.select
|
|||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import suwayomi.tachidesk.manga.impl.util.AndroidManifestParser
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools
|
import suwayomi.tachidesk.manga.impl.util.PackageTools
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
||||||
@@ -39,7 +39,6 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
|||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
|
||||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
|
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
|
||||||
import suwayomi.tachidesk.manga.impl.util.ResourceArscIconParser
|
|
||||||
import suwayomi.tachidesk.manga.impl.util.network.await
|
import suwayomi.tachidesk.manga.impl.util.network.await
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource
|
||||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
|
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
|
||||||
@@ -49,356 +48,148 @@ import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
|||||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
import suwayomi.tachidesk.server.ApplicationDirs
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.nio.file.Path
|
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
import java.util.zip.ZipInputStream
|
import java.util.zip.ZipInputStream
|
||||||
import java.util.zip.ZipOutputStream
|
import java.util.zip.ZipOutputStream
|
||||||
import kotlin.io.path.ExperimentalPathApi
|
|
||||||
import kotlin.io.path.Path
|
import kotlin.io.path.Path
|
||||||
import kotlin.io.path.absolutePathString
|
import kotlin.io.path.absolutePathString
|
||||||
import kotlin.io.path.copyTo
|
|
||||||
import kotlin.io.path.createDirectories
|
|
||||||
import kotlin.io.path.createParentDirectories
|
|
||||||
import kotlin.io.path.deleteExisting
|
|
||||||
import kotlin.io.path.deleteIfExists
|
|
||||||
import kotlin.io.path.deleteRecursively
|
|
||||||
import kotlin.io.path.div
|
|
||||||
import kotlin.io.path.exists
|
|
||||||
import kotlin.io.path.inputStream
|
|
||||||
import kotlin.io.path.isRegularFile
|
|
||||||
import kotlin.io.path.name
|
|
||||||
import kotlin.io.path.nameWithoutExtension
|
|
||||||
import kotlin.io.path.outputStream
|
import kotlin.io.path.outputStream
|
||||||
import kotlin.io.path.relativeTo
|
|
||||||
import kotlin.io.path.walk
|
|
||||||
|
|
||||||
object Extension {
|
object Extension {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val applicationDirs: ApplicationDirs by injectLazy()
|
private val applicationDirs: ApplicationDirs by injectLazy()
|
||||||
|
|
||||||
private suspend fun fetchExtensionFile(url: String): Path {
|
suspend fun installExtension(pkgName: String): Int {
|
||||||
val name = Uri.parse(url).lastPathSegment!!
|
|
||||||
val savePath = Path(applicationDirs.tempRoot) / "extensions" / name
|
|
||||||
// download jar file
|
|
||||||
downloadExtension(url, savePath)
|
|
||||||
|
|
||||||
return savePath
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun installExtension(pkgName: String): String {
|
|
||||||
logger.debug { "Installing $pkgName" }
|
logger.debug { "Installing $pkgName" }
|
||||||
val extension =
|
val apkUrl =
|
||||||
transaction {
|
transaction {
|
||||||
ExtensionTable
|
ExtensionTable
|
||||||
.select(ExtensionTable.apkUrl, ExtensionTable.jarUrl)
|
.select(ExtensionTable.apkUrl)
|
||||||
.where { ExtensionTable.pkgName eq pkgName }
|
.where { ExtensionTable.pkgName eq pkgName }
|
||||||
.firstOrNull()
|
.firstOrNull()
|
||||||
} ?: throw NullPointerException("Could not find extension for $pkgName")
|
?.get(ExtensionTable.apkUrl)
|
||||||
val jarUrl = extension[ExtensionTable.jarUrl]
|
} ?: throw NullPointerException("Could not find extension $pkgName")
|
||||||
val apkUrl = extension[ExtensionTable.apkUrl]
|
|
||||||
|
|
||||||
return when {
|
return installAPK {
|
||||||
jarUrl != null -> {
|
val apkName = Uri.parse(apkUrl).lastPathSegment!!
|
||||||
installExtension {
|
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
|
||||||
val jar = fetchExtensionFile(jarUrl)
|
// download apk file
|
||||||
val manifest = extractAndParseAndroidManifest(jar)
|
downloadAPKFile(apkUrl, apkSavePath)
|
||||||
ExtensionPackage.Jar(jar, manifest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apkUrl != null -> {
|
apkSavePath
|
||||||
installExtension {
|
|
||||||
val apk = fetchExtensionFile(apkUrl)
|
|
||||||
val packageInfo = getPackageInfo(apk)
|
|
||||||
ExtensionPackage.Apk(apk, packageInfo)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
|
||||||
throw NullPointerException("Could not find extension url for $pkgName")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyToExtensionsRoot(
|
|
||||||
inputStream: InputStream,
|
|
||||||
extensionName: String,
|
|
||||||
): Path {
|
|
||||||
val rootPath = Path(applicationDirs.tempRoot) / "extensions"
|
|
||||||
val downloadedFile = rootPath.resolve(extensionName).normalize()
|
|
||||||
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
|
|
||||||
"File '$extensionName' is not a valid extension file"
|
|
||||||
}
|
|
||||||
logger.debug { "Saving jar at $extensionName" }
|
|
||||||
// download jar file
|
|
||||||
downloadedFile.createParentDirectories()
|
|
||||||
downloadedFile.outputStream().buffered().use { out ->
|
|
||||||
inputStream.use {
|
|
||||||
it.copyTo(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return downloadedFile
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun installExternalExtension(
|
suspend fun installExternalExtension(
|
||||||
inputStream: InputStream,
|
inputStream: InputStream,
|
||||||
extensionName: String,
|
apkName: String,
|
||||||
): String =
|
): Int =
|
||||||
when {
|
installAPK(true) {
|
||||||
extensionName.endsWith(".jar") -> {
|
val rootPath = Path(applicationDirs.extensionsRoot)
|
||||||
installExtension(
|
val downloadedFile = rootPath.resolve(apkName).normalize()
|
||||||
true,
|
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
|
||||||
{
|
"File '$apkName' is not a valid extension file"
|
||||||
val jar = copyToExtensionsRoot(inputStream, extensionName)
|
|
||||||
val manifest = extractAndParseAndroidManifest(jar)
|
|
||||||
ExtensionPackage.Jar(jar, manifest)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
logger.debug { "Saving apk at $apkName" }
|
||||||
extensionName.endsWith(".apk") -> {
|
// download apk file
|
||||||
installExtension(
|
downloadedFile.outputStream().sink().buffer().use { sink ->
|
||||||
true,
|
inputStream.source().use { source ->
|
||||||
{
|
sink.writeAll(source)
|
||||||
val apk = copyToExtensionsRoot(inputStream, extensionName)
|
sink.flush()
|
||||||
val packageInfo = getPackageInfo(apk)
|
|
||||||
ExtensionPackage.Apk(apk, packageInfo)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
throw NullPointerException("Could not find extension type for $extensionName")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
downloadedFile.absolutePathString()
|
||||||
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(
|
suspend fun installAPK(
|
||||||
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,
|
forceReinstall: Boolean = false,
|
||||||
fetchPackage: suspend () -> ExtensionPackage,
|
fetcher: suspend () -> String,
|
||||||
): String {
|
): Int {
|
||||||
val extPackage = fetchPackage()
|
val apkFilePath = fetcher()
|
||||||
val metadata = extPackage.metadata
|
val apkName = File(apkFilePath).name
|
||||||
val pkgName = metadata.packageName
|
|
||||||
|
|
||||||
|
// check if we don't have the extension already installed
|
||||||
|
// if it's installed and we want to update, it first has to be uninstalled
|
||||||
val isInstalled =
|
val isInstalled =
|
||||||
transaction {
|
transaction {
|
||||||
ExtensionTable
|
ExtensionTable.selectAll().where { ExtensionTable.apkName eq apkName }.firstOrNull()
|
||||||
.select(ExtensionTable.isInstalled)
|
|
||||||
.where { ExtensionTable.pkgName eq pkgName }
|
|
||||||
.firstOrNull()
|
|
||||||
}?.get(ExtensionTable.isInstalled) ?: false
|
}?.get(ExtensionTable.isInstalled) ?: false
|
||||||
|
|
||||||
if (isInstalled) {
|
val fileNameWithoutType = apkName.substringBefore(".apk")
|
||||||
if (forceReinstall) {
|
|
||||||
|
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
|
||||||
|
val jarFilePath = "$dirPathWithoutType.jar"
|
||||||
|
|
||||||
|
val packageInfo = getPackageInfo(apkFilePath)
|
||||||
|
val pkgName = packageInfo.packageName
|
||||||
|
if (isInstalled && forceReinstall) {
|
||||||
uninstallExtension(pkgName)
|
uninstallExtension(pkgName)
|
||||||
} else {
|
|
||||||
extPackage.file.deleteExisting()
|
|
||||||
return pkgName
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata.reqFeatures.contains(EXTENSION_FEATURE)) {
|
if (!isInstalled || forceReinstall) {
|
||||||
extPackage.file.deleteExisting()
|
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
|
||||||
throw Exception("This file is not a Tachiyomi extension")
|
throw Exception("This apk is not a Tachiyomi extension")
|
||||||
}
|
}
|
||||||
|
|
||||||
val libVersion = metadata.versionName.substringBeforeLast('.').toDouble()
|
// Validate lib version
|
||||||
|
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
|
||||||
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||||
extPackage.file.deleteExisting()
|
|
||||||
throw Exception(
|
throw Exception(
|
||||||
"Lib version is $libVersion, while only versions " +
|
"Lib version is $libVersion, while only versions " +
|
||||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
|
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentWarning =
|
// TODO: allow trusting keys
|
||||||
extPackage.metadata
|
// val signatureHash = getSignatureHash(packageInfo)
|
||||||
.metaData
|
|
||||||
.getInt(METADATA_CONTENT_WARNING)
|
// 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)
|
||||||
if (contentWarning == 0) {
|
if (contentWarning == 0) {
|
||||||
contentWarning = extPackage.metadata
|
contentWarning = packageInfo.applicationInfo.metaData
|
||||||
.metaData
|
|
||||||
.getString(METADATA_CONTENT_WARNING)
|
.getString(METADATA_CONTENT_WARNING)
|
||||||
?.toIntOrNull() ?: 0
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
if (contentWarning == 0) {
|
if (contentWarning == 0) {
|
||||||
contentWarning = extPackage.metadata
|
contentWarning = packageInfo.applicationInfo.metaData
|
||||||
.metaData
|
|
||||||
.getString(METADATA_NSFW)
|
.getString(METADATA_NSFW)
|
||||||
?.toIntOrNull() ?: 0
|
?.toIntOrNull()
|
||||||
|
?: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val sourceClass =
|
val sourceClass =
|
||||||
metadata.metaData
|
packageInfo.applicationInfo.metaData
|
||||||
.getString(METADATA_SOURCE_CLASS)!!
|
.getString(METADATA_SOURCE_CLASS)!!
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
val className =
|
val className =
|
||||||
if (sourceClass.startsWith(".")) {
|
if (sourceClass.startsWith(".")) {
|
||||||
pkgName + sourceClass
|
packageInfo.packageName + sourceClass
|
||||||
} else {
|
} else {
|
||||||
sourceClass
|
sourceClass
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug { "Main class for extension is $className" }
|
logger.debug { "Main class for extension is $className" }
|
||||||
|
|
||||||
val extensionsRoot = Path(applicationDirs.extensionsRoot)
|
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
||||||
val jarFile = extPackage.prepareJarAndIcons(extensionsRoot)
|
extractAssetsFromApk(apkFilePath, jarFilePath)
|
||||||
|
extractAndCacheApkIcon(apkFilePath, packageInfo.packageName)
|
||||||
|
|
||||||
|
// clean up
|
||||||
|
File(apkFilePath).delete()
|
||||||
|
|
||||||
try {
|
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
|
// collect sources from the extension
|
||||||
val extensionMainClassInstance = loadExtensionSources(jarFile, className)
|
val extensionMainClassInstance = loadExtensionSources(jarFilePath, className)
|
||||||
val sources: List<Source> =
|
val sources: List<Source> =
|
||||||
when (extensionMainClassInstance) {
|
when (extensionMainClassInstance) {
|
||||||
is Source -> listOf(extensionMainClassInstance)
|
is Source -> listOf(extensionMainClassInstance)
|
||||||
@@ -414,15 +205,27 @@ object Extension {
|
|||||||
else -> "all"
|
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
|
// update extension info
|
||||||
transaction {
|
transaction {
|
||||||
if (ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
|
if (ExtensionTable.selectAll().where { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
|
||||||
ExtensionTable.insert {
|
ExtensionTable.insert {
|
||||||
it[this.apkName] = apkName
|
it[this.apkName] = apkName
|
||||||
it[name] = extensionName
|
it[name] = extensionName
|
||||||
it[this.pkgName] = pkgName
|
it[this.pkgName] = packageInfo.packageName
|
||||||
it[this.versionName] = versionName
|
it[versionName] = packageInfo.versionName
|
||||||
it[this.versionCode] = versionCode.toLong()
|
it[versionCode] = packageInfo.versionCode.toLong()
|
||||||
it[extensionLib] = extensionLibVersion
|
it[extensionLib] = extensionLibVersion
|
||||||
it[lang] = extensionLang
|
it[lang] = extensionLang
|
||||||
it[this.contentWarning] = contentWarning
|
it[this.contentWarning] = contentWarning
|
||||||
@@ -433,8 +236,8 @@ object Extension {
|
|||||||
it[this.apkName] = apkName
|
it[this.apkName] = apkName
|
||||||
it[this.isInstalled] = true
|
it[this.isInstalled] = true
|
||||||
it[this.classFQName] = className
|
it[this.classFQName] = className
|
||||||
it[this.versionName] = versionName
|
it[versionName] = packageInfo.versionName
|
||||||
it[this.versionCode] = versionCode.toLong()
|
it[versionCode] = packageInfo.versionCode.toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
val extensionId =
|
val extensionId =
|
||||||
@@ -455,15 +258,28 @@ object Extension {
|
|||||||
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
|
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(
|
private fun extractAndCacheApkIcon(
|
||||||
apkFile: Path,
|
apkFilePath: String,
|
||||||
pkgName: String,
|
pkgName: String,
|
||||||
) {
|
) {
|
||||||
|
val iconCacheDir = "${applicationDirs.extensionsRoot}/icon"
|
||||||
try {
|
try {
|
||||||
val iconData =
|
val iconData =
|
||||||
ApkFile(apkFile.toFile()).use { apk ->
|
ApkFile(File(apkFilePath)).use { apk ->
|
||||||
apk.allIcons
|
apk.allIcons
|
||||||
.filterIsInstance<Icon>()
|
.filterIsInstance<Icon>()
|
||||||
.mapNotNull { it.data?.let { data -> data to it.density } }
|
.mapNotNull { it.data?.let { data -> data to it.density } }
|
||||||
@@ -474,36 +290,31 @@ object Extension {
|
|||||||
logger.warn { "No icon found in APK $pkgName" }
|
logger.warn { "No icon found in APK $pkgName" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cacheIcon(pkgName, iconData.inputStream())
|
|
||||||
|
File(iconCacheDir).mkdirs()
|
||||||
|
clearCachedImage(iconCacheDir, pkgName)
|
||||||
|
saveImage("$iconCacheDir/$pkgName", iconData.inputStream(), null)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn(e) { "Failed to extract icon from APK $pkgName" }
|
logger.warn(e) { "Failed to extract icon from APK $pkgName" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cacheIcon(
|
|
||||||
pkgName: String,
|
|
||||||
inputStream: InputStream,
|
|
||||||
) {
|
|
||||||
val iconCacheDir = Path(applicationDirs.extensionsRoot) / "icon"
|
|
||||||
iconCacheDir.createDirectories()
|
|
||||||
clearCachedImage(iconCacheDir.absolutePathString(), pkgName)
|
|
||||||
saveImage("$iconCacheDir/$pkgName", inputStream, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPathApi::class)
|
|
||||||
private fun extractAssetsFromApk(
|
private fun extractAssetsFromApk(
|
||||||
apkFile: Path,
|
apkPath: String,
|
||||||
jarFile: Path,
|
jarPath: String,
|
||||||
) {
|
) {
|
||||||
val assetsFolder = apkFile.parent / "${apkFile.nameWithoutExtension}_assets"
|
val apkFile = File(apkPath)
|
||||||
assetsFolder.createDirectories()
|
val jarFile = File(jarPath)
|
||||||
|
|
||||||
|
val assetsFolder = File("${apkFile.parent}/${apkFile.nameWithoutExtension}_assets")
|
||||||
|
assetsFolder.mkdir()
|
||||||
ZipInputStream(apkFile.inputStream()).use { zipInputStream ->
|
ZipInputStream(apkFile.inputStream()).use { zipInputStream ->
|
||||||
var zipEntry = zipInputStream.nextEntry
|
var zipEntry = zipInputStream.nextEntry
|
||||||
while (zipEntry != null) {
|
while (zipEntry != null) {
|
||||||
if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) {
|
if (zipEntry.name.startsWith("assets/") && !zipEntry.isDirectory) {
|
||||||
val assetFile = assetsFolder / zipEntry.name
|
val assetFile = File(assetsFolder, zipEntry.name)
|
||||||
assetFile.createParentDirectories()
|
assetFile.parentFile.mkdirs()
|
||||||
assetFile.outputStream().use { outputStream ->
|
FileOutputStream(assetFile).use { outputStream ->
|
||||||
zipInputStream.copyTo(outputStream)
|
zipInputStream.copyTo(outputStream)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,9 +322,9 @@ object Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val tempJarFile = jarFile.parent / "${jarFile.nameWithoutExtension}_temp.jar"
|
val tempJarFile = File("${jarFile.parent}/${jarFile.nameWithoutExtension}_temp.jar")
|
||||||
ZipInputStream(jarFile.inputStream()).use { jarZipInputStream ->
|
ZipInputStream(jarFile.inputStream()).use { jarZipInputStream ->
|
||||||
ZipOutputStream(tempJarFile.outputStream()).use { jarZipOutputStream ->
|
ZipOutputStream(FileOutputStream(tempJarFile)).use { jarZipOutputStream ->
|
||||||
var zipEntry = jarZipInputStream.nextEntry
|
var zipEntry = jarZipInputStream.nextEntry
|
||||||
while (zipEntry != null) {
|
while (zipEntry != null) {
|
||||||
if (!zipEntry.name.startsWith("META-INF/")) {
|
if (!zipEntry.name.startsWith("META-INF/")) {
|
||||||
@@ -522,8 +333,8 @@ object Extension {
|
|||||||
}
|
}
|
||||||
zipEntry = jarZipInputStream.nextEntry
|
zipEntry = jarZipInputStream.nextEntry
|
||||||
}
|
}
|
||||||
assetsFolder.walk().forEach { file ->
|
assetsFolder.walkTopDown().forEach { file ->
|
||||||
if (file.isRegularFile()) {
|
if (file.isFile) {
|
||||||
jarZipOutputStream.putNextEntry(ZipEntry(file.relativeTo(assetsFolder).toString().replace("\\", "/")))
|
jarZipOutputStream.putNextEntry(ZipEntry(file.relativeTo(assetsFolder).toString().replace("\\", "/")))
|
||||||
file.inputStream().use { inputStream ->
|
file.inputStream().use { inputStream ->
|
||||||
inputStream.copyTo(jarZipOutputStream)
|
inputStream.copyTo(jarZipOutputStream)
|
||||||
@@ -534,25 +345,17 @@ object Extension {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jarFile.deleteIfExists()
|
jarFile.delete()
|
||||||
tempJarFile.copyTo(jarFile)
|
tempJarFile.renameTo(jarFile)
|
||||||
tempJarFile.deleteExisting()
|
|
||||||
|
|
||||||
assetsFolder.deleteRecursively()
|
assetsFolder.deleteRecursively()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAndParseAndroidManifest(jar: Path): AndroidManifestParser.AndroidManifest =
|
|
||||||
ZipFile.builder().setPath(jar).get().use { jarZip ->
|
|
||||||
jarZip.getInputStream(jarZip.getEntry("AndroidManifest.xml")).use {
|
|
||||||
AndroidManifestParser.parse(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val network: NetworkHelper by injectLazy()
|
private val network: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
private suspend fun downloadExtension(
|
private suspend fun downloadAPKFile(
|
||||||
url: String,
|
url: String,
|
||||||
savePath: Path,
|
savePath: String,
|
||||||
) {
|
) {
|
||||||
val response =
|
val response =
|
||||||
network.client
|
network.client
|
||||||
@@ -560,10 +363,11 @@ object Extension {
|
|||||||
GET(url, cache = CacheControl.FORCE_NETWORK),
|
GET(url, cache = CacheControl.FORCE_NETWORK),
|
||||||
).await()
|
).await()
|
||||||
|
|
||||||
savePath.createParentDirectories()
|
val downloadedFile = File(savePath)
|
||||||
response.body.byteStream().use {
|
downloadedFile.sink().buffer().use { sink ->
|
||||||
savePath.outputStream().buffered().use { out ->
|
response.body.source().use { source ->
|
||||||
it.copyTo(out)
|
sink.writeAll(source)
|
||||||
|
sink.flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -575,7 +379,7 @@ object Extension {
|
|||||||
val fileNameWithoutType =
|
val fileNameWithoutType =
|
||||||
extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk")
|
extensionRecord[ExtensionTable.apkName]?.substringBefore(".apk")
|
||||||
?: throw NullPointerException("Missing $pkgName apkName")
|
?: throw NullPointerException("Missing $pkgName apkName")
|
||||||
val jarPath = Path(applicationDirs.extensionsRoot) / "$fileNameWithoutType.jar"
|
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
||||||
val sources =
|
val sources =
|
||||||
transaction {
|
transaction {
|
||||||
val extensionId = extensionRecord[ExtensionTable.id].value
|
val extensionId = extensionRecord[ExtensionTable.id].value
|
||||||
@@ -597,18 +401,18 @@ object Extension {
|
|||||||
sources
|
sources
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jarPath.exists()) {
|
if (File(jarPath).exists()) {
|
||||||
// free up the file descriptor if exists
|
// free up the file descriptor if exists
|
||||||
PackageTools.jarLoaderMap.remove(jarPath.absolutePathString())?.close()
|
PackageTools.jarLoaderMap.remove(jarPath)?.close()
|
||||||
|
|
||||||
// clear all loaded sources
|
// clear all loaded sources
|
||||||
sources.forEach { GetSource.unregisterSource(it) }
|
sources.forEach { GetSource.unregisterSource(it) }
|
||||||
|
|
||||||
jarPath.deleteExisting()
|
File(jarPath).delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateExtension(pkgName: String): String {
|
suspend fun updateExtension(pkgName: String): Int {
|
||||||
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
|
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
|
||||||
uninstallExtension(pkgName)
|
uninstallExtension(pkgName)
|
||||||
transaction {
|
transaction {
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ object ExtensionsList {
|
|||||||
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
||||||
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
|
this[ExtensionTable.storeIndexUrl] = foundExtension.storeIndexUrl
|
||||||
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
|
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
|
||||||
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
|
|
||||||
|
|
||||||
// add these because batch updates need matching columns
|
// add these because batch updates need matching columns
|
||||||
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
|
this[ExtensionTable.hasUpdate] = extensionRecord[ExtensionTable.hasUpdate]
|
||||||
@@ -177,7 +176,6 @@ object ExtensionsList {
|
|||||||
this[ExtensionTable.lang] = foundExtension.lang
|
this[ExtensionTable.lang] = foundExtension.lang
|
||||||
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
|
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
|
||||||
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
|
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
|
||||||
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
|
|
||||||
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
||||||
}
|
}
|
||||||
}.toExecutable()
|
}.toExecutable()
|
||||||
@@ -195,7 +193,6 @@ object ExtensionsList {
|
|||||||
this[ExtensionTable.lang] = foundExtension.lang
|
this[ExtensionTable.lang] = foundExtension.lang
|
||||||
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
|
this[ExtensionTable.contentWarning] = foundExtension.contentWarning.ordinal
|
||||||
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
|
this[ExtensionTable.apkUrl] = foundExtension.apkUrl
|
||||||
this[ExtensionTable.jarUrl] = foundExtension.jarUrl
|
|
||||||
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
this[ExtensionTable.iconUrl] = foundExtension.iconUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ data class NetworkExtensionStore(
|
|||||||
data class Resources(
|
data class Resources(
|
||||||
@ProtoNumber(1) val apkUrl: String,
|
@ProtoNumber(1) val apkUrl: String,
|
||||||
@ProtoNumber(2) val iconUrl: String,
|
@ProtoNumber(2) val iconUrl: String,
|
||||||
// Keiyoushi specific output
|
|
||||||
@ProtoNumber(501) val jarUrl: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -111,7 +109,6 @@ fun NetworkExtensionStore.ExtensionList.toExtensionInfos(store: ExtensionStore):
|
|||||||
name = extension.name,
|
name = extension.name,
|
||||||
pkgName = extension.packageName,
|
pkgName = extension.packageName,
|
||||||
apkUrl = extension.resources.apkUrl,
|
apkUrl = extension.resources.apkUrl,
|
||||||
jarUrl = extension.resources.jarUrl,
|
|
||||||
iconUrl = extension.resources.iconUrl,
|
iconUrl = extension.resources.iconUrl,
|
||||||
extensionLib = extension.extensionLib,
|
extensionLib = extension.extensionLib,
|
||||||
versionCode = extension.versionCode,
|
versionCode = extension.versionCode,
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ fun NetworkLegacyExtension.toExtensionInfo(
|
|||||||
name = name.substringAfter("Tachiyomi: "),
|
name = name.substringAfter("Tachiyomi: "),
|
||||||
pkgName = pkg,
|
pkgName = pkg,
|
||||||
apkUrl = "$storeBaseUrl/apk/$apk",
|
apkUrl = "$storeBaseUrl/apk/$apk",
|
||||||
jarUrl = null,
|
|
||||||
iconUrl = "$storeBaseUrl/icon/$pkg.png",
|
iconUrl = "$storeBaseUrl/icon/$pkg.png",
|
||||||
extensionLib = version.substringBeforeLast('.'),
|
extensionLib = version.substringBeforeLast('.'),
|
||||||
versionCode = code,
|
versionCode = code,
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.impl.util
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi
|
|
||||||
import nl.adaptivity.xmlutil.XmlDeclMode
|
|
||||||
import nl.adaptivity.xmlutil.core.KtXmlReader
|
|
||||||
import nl.adaptivity.xmlutil.serialization.UnknownChildHandler
|
|
||||||
import nl.adaptivity.xmlutil.serialization.XML
|
|
||||||
import nl.adaptivity.xmlutil.serialization.XmlElement
|
|
||||||
import nl.adaptivity.xmlutil.serialization.XmlSerialName
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
object AndroidManifestParser {
|
|
||||||
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("manifest", "", "")
|
|
||||||
data class AndroidManifest(
|
|
||||||
@XmlSerialName("package", "", "")
|
|
||||||
val packageName: String,
|
|
||||||
@XmlSerialName("versionCode", ANDROID_NS, "android")
|
|
||||||
val versionCode: Int? = null,
|
|
||||||
@XmlSerialName("versionName", ANDROID_NS, "android")
|
|
||||||
val versionName: String? = null,
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("uses-sdk", "", "")
|
|
||||||
val usesSdk: UsesSdk? = null,
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("uses-feature", "", "")
|
|
||||||
val usesFeatures: List<UsesFeature> = emptyList(),
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("application", "", "")
|
|
||||||
val application: Application? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("uses-sdk", "", "")
|
|
||||||
data class UsesSdk(
|
|
||||||
@XmlSerialName("minSdkVersion", ANDROID_NS, "android")
|
|
||||||
val minSdkVersion: Int? = null,
|
|
||||||
@XmlSerialName("targetSdkVersion", ANDROID_NS, "android")
|
|
||||||
val targetSdkVersion: Int? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("uses-feature", "", "")
|
|
||||||
data class UsesFeature(
|
|
||||||
@XmlSerialName("name", ANDROID_NS, "android")
|
|
||||||
val name: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("application", "", "")
|
|
||||||
data class Application(
|
|
||||||
@XmlSerialName("label", ANDROID_NS, "android")
|
|
||||||
val label: String? = null,
|
|
||||||
@XmlSerialName("icon", ANDROID_NS, "android")
|
|
||||||
val icon: String? = null,
|
|
||||||
@XmlSerialName("allowBackup", ANDROID_NS, "android")
|
|
||||||
val allowBackup: Boolean? = null,
|
|
||||||
@XmlSerialName("extractNativeLibs", ANDROID_NS, "android")
|
|
||||||
val extractNativeLibs: Boolean? = null,
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("meta-data", "", "")
|
|
||||||
val metaData: List<MetaData> = emptyList(),
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("activity", "", "")
|
|
||||||
val activities: List<Activity> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("meta-data", "", "")
|
|
||||||
data class MetaData(
|
|
||||||
@XmlSerialName("name", ANDROID_NS, "android")
|
|
||||||
val name: String,
|
|
||||||
@XmlSerialName("value", ANDROID_NS, "android")
|
|
||||||
val value: String? = null,
|
|
||||||
@XmlSerialName("resource", ANDROID_NS, "android")
|
|
||||||
val resource: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("activity", "", "")
|
|
||||||
data class Activity(
|
|
||||||
@XmlSerialName("name", ANDROID_NS, "android")
|
|
||||||
val name: String,
|
|
||||||
@XmlSerialName("exported", ANDROID_NS, "android")
|
|
||||||
val exported: Boolean? = null,
|
|
||||||
@XmlSerialName("theme", ANDROID_NS, "android")
|
|
||||||
val theme: String? = null,
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("intent-filter", "", "")
|
|
||||||
val intentFilters: List<IntentFilter> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("intent-filter", "", "")
|
|
||||||
data class IntentFilter(
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("action", "", "")
|
|
||||||
val actions: List<Action> = emptyList(),
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("category", "", "")
|
|
||||||
val categories: List<Category> = emptyList(),
|
|
||||||
@XmlElement(true)
|
|
||||||
@XmlSerialName("data", "", "")
|
|
||||||
val data: List<Data> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("action", "", "")
|
|
||||||
data class Action(
|
|
||||||
@XmlSerialName("name", ANDROID_NS, "android")
|
|
||||||
val name: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("category", "", "")
|
|
||||||
data class Category(
|
|
||||||
@XmlSerialName("name", ANDROID_NS, "android")
|
|
||||||
val name: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
@XmlSerialName("data", "", "")
|
|
||||||
data class Data(
|
|
||||||
@XmlSerialName("scheme", ANDROID_NS, "android")
|
|
||||||
val scheme: String? = null,
|
|
||||||
@XmlSerialName("host", ANDROID_NS, "android")
|
|
||||||
val host: String? = null,
|
|
||||||
@XmlSerialName("pathPattern", ANDROID_NS, "android")
|
|
||||||
val pathPattern: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalXmlUtilApi::class)
|
|
||||||
private val xml =
|
|
||||||
XML {
|
|
||||||
autoPolymorphic = false
|
|
||||||
repairNamespaces = true
|
|
||||||
xmlDeclMode = XmlDeclMode.Minimal
|
|
||||||
defaultPolicy {
|
|
||||||
unknownChildHandler = UnknownChildHandler { _, _, _, _, _ -> emptyList() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalXmlUtilApi::class)
|
|
||||||
fun parse(input: InputStream): AndroidManifest = xml.decodeFromReader(AndroidManifest.serializer(), KtXmlReader(input))
|
|
||||||
}
|
|
||||||
@@ -23,14 +23,13 @@ import suwayomi.tachidesk.server.ApplicationDirs
|
|||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
|
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
|
||||||
import xyz.nulldev.androidcompat.pm.toPackageInfo
|
import xyz.nulldev.androidcompat.pm.toPackageInfo
|
||||||
|
import java.io.File
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.net.URLClassLoader
|
import java.net.URLClassLoader
|
||||||
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import javax.xml.parsers.DocumentBuilderFactory
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
import kotlin.io.path.Path
|
import kotlin.io.path.Path
|
||||||
import kotlin.io.path.absolutePathString
|
|
||||||
import kotlin.io.path.nameWithoutExtension
|
|
||||||
import kotlin.io.path.readBytes
|
|
||||||
import kotlin.io.path.relativeTo
|
import kotlin.io.path.relativeTo
|
||||||
|
|
||||||
object PackageTools {
|
object PackageTools {
|
||||||
@@ -53,13 +52,15 @@ object PackageTools {
|
|||||||
* Convert dex to jar, a wrapper for the dex2jar library
|
* Convert dex to jar, a wrapper for the dex2jar library
|
||||||
*/
|
*/
|
||||||
fun dex2jar(
|
fun dex2jar(
|
||||||
dexFile: Path,
|
dexFile: String,
|
||||||
jarFile: Path,
|
jarFile: String,
|
||||||
|
fileNameWithoutType: String,
|
||||||
) {
|
) {
|
||||||
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
|
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
|
||||||
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
|
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
|
||||||
|
|
||||||
val reader = MultiDexFileReader.open(dexFile.readBytes())
|
val jarFilePath = File(jarFile).toPath()
|
||||||
|
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
|
||||||
val handler = BaksmaliBaseDexExceptionHandler()
|
val handler = BaksmaliBaseDexExceptionHandler()
|
||||||
Dex2jar
|
Dex2jar
|
||||||
.from(reader)
|
.from(reader)
|
||||||
@@ -72,10 +73,10 @@ object PackageTools {
|
|||||||
.noCode(false)
|
.noCode(false)
|
||||||
.skipExceptions(false)
|
.skipExceptions(false)
|
||||||
.dontSanitizeNames(true)
|
.dontSanitizeNames(true)
|
||||||
.to(jarFile)
|
.to(jarFilePath)
|
||||||
if (handler.hasException()) {
|
if (handler.hasException()) {
|
||||||
val rootPath = Path(applicationDirs.extensionsRoot)
|
val rootPath = Path(applicationDirs.extensionsRoot)
|
||||||
val errorFile: Path = rootPath.resolve("${dexFile.nameWithoutExtension}-error.txt")
|
val errorFile: Path = rootPath.resolve("$fileNameWithoutType-error.txt")
|
||||||
logger.error {
|
logger.error {
|
||||||
"""
|
"""
|
||||||
Detail Error Information in File ${errorFile.relativeTo(rootPath)}
|
Detail Error Information in File ${errorFile.relativeTo(rootPath)}
|
||||||
@@ -88,14 +89,15 @@ object PackageTools {
|
|||||||
}
|
}
|
||||||
handler.dump(errorFile, emptyArray<String>())
|
handler.dump(errorFile, emptyArray<String>())
|
||||||
} else {
|
} else {
|
||||||
BytecodeEditor.fixAndroidClasses(jarFile)
|
BytecodeEditor.fixAndroidClasses(jarFilePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
|
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
|
||||||
fun getPackageInfo(apkFile: Path): PackageInfo =
|
fun getPackageInfo(apkFilePath: String): PackageInfo {
|
||||||
ApkParsers.getMetaInfo(apkFile.toFile()).toPackageInfo(apkFile.toFile()).apply {
|
val apk = File(apkFilePath)
|
||||||
ApkFile(apkFile.toFile()).use { parsed ->
|
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
|
||||||
|
val parsed = ApkFile(apk)
|
||||||
val dbFactory = DocumentBuilderFactory.newInstance()
|
val dbFactory = DocumentBuilderFactory.newInstance()
|
||||||
val dBuilder = dbFactory.newDocumentBuilder()
|
val dBuilder = dbFactory.newDocumentBuilder()
|
||||||
val doc =
|
val doc =
|
||||||
@@ -154,19 +156,19 @@ object PackageTools {
|
|||||||
* It may return an instance of HttpSource or SourceFactory depending on the extension.
|
* It may return an instance of HttpSource or SourceFactory depending on the extension.
|
||||||
*/
|
*/
|
||||||
fun loadExtensionSources(
|
fun loadExtensionSources(
|
||||||
jar: Path,
|
jarPath: String,
|
||||||
className: String,
|
className: String,
|
||||||
): Any {
|
): Any {
|
||||||
try {
|
try {
|
||||||
logger.debug { "loading jar with path: ${jar.absolutePathString()}" }
|
logger.debug { "loading jar with path: $jarPath" }
|
||||||
val classLoader = jarLoaderMap[jar.absolutePathString()] ?: ChildFirstURLClassLoader(arrayOf<URL>(jar.toUri().toURL()))
|
val classLoader = jarLoaderMap[jarPath] ?: ChildFirstURLClassLoader(arrayOf<URL>(Path(jarPath).toUri().toURL()))
|
||||||
val classToLoad = Class.forName(className, false, classLoader)
|
val classToLoad = Class.forName(className, false, classLoader)
|
||||||
|
|
||||||
jarLoaderMap[jar.absolutePathString()] = classLoader
|
jarLoaderMap[jarPath] = classLoader
|
||||||
|
|
||||||
return classToLoad.getDeclaredConstructor().newInstance()
|
return classToLoad.getDeclaredConstructor().newInstance()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "Failed to load jar with path: ${jar.absolutePathString()}" }
|
logger.error(e) { "Failed to load jar with path: $jarPath" }
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
package suwayomi.tachidesk.manga.impl.util
|
|
||||||
|
|
||||||
import nl.adaptivity.xmlutil.core.impl.multiplatform.InputStream
|
|
||||||
import org.apache.commons.compress.archivers.zip.ZipFile
|
|
||||||
import pxb.android.arsc.ArscParser
|
|
||||||
import pxb.android.arsc.Config
|
|
||||||
import pxb.android.arsc.Pkg
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
|
|
||||||
object ResourceArscIconParser {
|
|
||||||
private data class IconCandidate(
|
|
||||||
val density: Int,
|
|
||||||
val path: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun extractIcon(zip: ZipFile): InputStream {
|
|
||||||
val packages =
|
|
||||||
zip
|
|
||||||
.getInputStream(zip.getEntry("resources.arsc"))
|
|
||||||
.use { ArscParser(it.readBytes()).parse() }
|
|
||||||
|
|
||||||
val icon =
|
|
||||||
packages
|
|
||||||
.flatMap { it.iconCandidates() }
|
|
||||||
.maxByOrNull { it.density }
|
|
||||||
?: throw NullPointerException("No valid icons")
|
|
||||||
|
|
||||||
val entry =
|
|
||||||
zip.getEntry(icon.path)
|
|
||||||
?: throw NullPointerException("Icon ${icon.path} missing")
|
|
||||||
|
|
||||||
return zip.getInputStream(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Pkg.iconCandidates(): List<IconCandidate> =
|
|
||||||
types.values
|
|
||||||
.filter { it.name == "mipmap" || it.name == "drawable" }
|
|
||||||
.flatMap {
|
|
||||||
it.configs.flatMap {
|
|
||||||
val density = it.density()
|
|
||||||
|
|
||||||
it.resources.values
|
|
||||||
.asSequence()
|
|
||||||
.filter { it.spec.name == "ic_launcher" }
|
|
||||||
.map { it.value.toString() }
|
|
||||||
.filter(::isRasterImage)
|
|
||||||
.map { IconCandidate(density, it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Config.density(): Int =
|
|
||||||
ByteBuffer
|
|
||||||
.wrap(id)
|
|
||||||
.order(ByteOrder.LITTLE_ENDIAN)
|
|
||||||
.getShort(14)
|
|
||||||
.toInt() and 0xffff
|
|
||||||
|
|
||||||
private val rasterExtensions =
|
|
||||||
setOf(
|
|
||||||
"png",
|
|
||||||
"webp",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun isRasterImage(path: String): Boolean =
|
|
||||||
path
|
|
||||||
.substringAfterLast('.', "")
|
|
||||||
.lowercase() in rasterExtensions
|
|
||||||
}
|
|
||||||
@@ -20,8 +20,6 @@ import suwayomi.tachidesk.manga.model.table.SourceTable
|
|||||||
import suwayomi.tachidesk.server.ApplicationDirs
|
import suwayomi.tachidesk.server.ApplicationDirs
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.io.path.Path
|
|
||||||
import kotlin.io.path.div
|
|
||||||
|
|
||||||
object GetSource {
|
object GetSource {
|
||||||
private val logger = KotlinLogging.logger { }
|
private val logger = KotlinLogging.logger { }
|
||||||
@@ -51,7 +49,7 @@ object GetSource {
|
|||||||
?: throw NullPointerException("Missing apkName")
|
?: throw NullPointerException("Missing apkName")
|
||||||
val className = extensionRecord[ExtensionTable.classFQName]
|
val className = extensionRecord[ExtensionTable.classFQName]
|
||||||
val jarName = apkName.substringBefore(".apk") + ".jar"
|
val jarName = apkName.substringBefore(".apk") + ".jar"
|
||||||
val jarPath = Path(applicationDirs.extensionsRoot) / jarName
|
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
|
||||||
|
|
||||||
when (val instance = loadExtensionSources(jarPath, className)) {
|
when (val instance = loadExtensionSources(jarPath, className)) {
|
||||||
is Source -> listOf(instance)
|
is Source -> listOf(instance)
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ data class ExtensionInfo(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val pkgName: String,
|
val pkgName: String,
|
||||||
val apkUrl: String,
|
val apkUrl: String,
|
||||||
val jarUrl: String?,
|
|
||||||
val iconUrl: String,
|
val iconUrl: String,
|
||||||
val extensionLib: String,
|
val extensionLib: String,
|
||||||
val versionCode: Long,
|
val versionCode: Long,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ object ExtensionTable : IntIdTable() {
|
|||||||
val name = varchar("name", 128)
|
val name = varchar("name", 128)
|
||||||
val pkgName = varchar("pkg_name", 128)
|
val pkgName = varchar("pkg_name", 128)
|
||||||
val apkUrl = varchar("apk_url", 2048).nullable()
|
val apkUrl = varchar("apk_url", 2048).nullable()
|
||||||
val jarUrl = varchar("jar_url", 2048).nullable()
|
|
||||||
val extensionLib = varchar("extension_lib", 16).nullable()
|
val extensionLib = varchar("extension_lib", 16).nullable()
|
||||||
val versionName = varchar("version_name", 16)
|
val versionName = varchar("version_name", 16)
|
||||||
val versionCode = long("version_code")
|
val versionCode = long("version_code")
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package suwayomi.tachidesk.server.database.migration
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Copyright (C) Contributors to the Suwayomi project
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
||||||
|
|
||||||
import de.neonew.exposed.migrations.helpers.AddColumnMigration
|
|
||||||
|
|
||||||
@Suppress("ClassName", "unused")
|
|
||||||
class M0059_AddJarUrlToExtensionTable :
|
|
||||||
AddColumnMigration(
|
|
||||||
"extension",
|
|
||||||
"jar_url",
|
|
||||||
"VARCHAR(2048)",
|
|
||||||
"NULL",
|
|
||||||
)
|
|
||||||
@@ -27,7 +27,6 @@ import suwayomi.tachidesk.manga.impl.Source.getSourceList
|
|||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
|
import suwayomi.tachidesk.manga.impl.extension.Extension.installExtension
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
|
import suwayomi.tachidesk.manga.impl.extension.Extension.uninstallExtension
|
||||||
import suwayomi.tachidesk.manga.impl.extension.Extension.updateExtension
|
import suwayomi.tachidesk.manga.impl.extension.Extension.updateExtension
|
||||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionStoreService
|
|
||||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.getExtensionList
|
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.getExtensionList
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrNull
|
import suwayomi.tachidesk.manga.impl.util.source.GetSource.getSourceOrNull
|
||||||
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
||||||
@@ -51,8 +50,6 @@ class TestExtensionCompatibility {
|
|||||||
private val chaptersToFetch = mutableListOf<Triple<HttpSource, SManga, SChapter>>()
|
private val chaptersToFetch = mutableListOf<Triple<HttpSource, SManga, SChapter>>()
|
||||||
private val chaptersPageListFailedToFetch = mutableListOf<Triple<HttpSource, Pair<SManga, SChapter>, Exception>>()
|
private val chaptersPageListFailedToFetch = mutableListOf<Triple<HttpSource, Pair<SManga, SChapter>, Exception>>()
|
||||||
|
|
||||||
val repos = listOf<String>()
|
|
||||||
|
|
||||||
@BeforeAll
|
@BeforeAll
|
||||||
fun setup() {
|
fun setup() {
|
||||||
val dataRoot = File(BASE_PATH).absolutePath
|
val dataRoot = File(BASE_PATH).absolutePath
|
||||||
@@ -60,14 +57,6 @@ class TestExtensionCompatibility {
|
|||||||
Looper.clearMainLooperForTest()
|
Looper.clearMainLooperForTest()
|
||||||
SettingsRegistry.clear()
|
SettingsRegistry.clear()
|
||||||
applicationSetup()
|
applicationSetup()
|
||||||
repos.forEach {
|
|
||||||
val store =
|
|
||||||
runBlocking {
|
|
||||||
ExtensionStoreService.fetch(it)
|
|
||||||
}
|
|
||||||
ExtensionStoreService.upsert(store)
|
|
||||||
}
|
|
||||||
ExtensionStoreService.syncDbToPrefs()
|
|
||||||
setLoggingEnabled(false)
|
setLoggingEnabled(false)
|
||||||
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -83,10 +72,7 @@ class TestExtensionCompatibility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
try {
|
|
||||||
uninstallExtension(it.pkgName)
|
uninstallExtension(it.pkgName)
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
installExtension(it.pkgName)
|
installExtension(it.pkgName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,9 +135,9 @@ class TestExtensionCompatibility {
|
|||||||
repeat { source.getMangaUpdate(manga, emptyList(), true, true) }
|
repeat { source.getMangaUpdate(manga, emptyList(), true, true) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn {
|
logger.warn {
|
||||||
"Failed to fetch manga info and chapters from $source for ${manga.title} (${source.getMangaUrl(
|
"Failed to fetch manga info and chapters from $source for ${manga.title} (${source.mangaDetailsRequest(
|
||||||
manga,
|
manga,
|
||||||
)}): ${e.message}"
|
).url}): ${e.message}"
|
||||||
}
|
}
|
||||||
mangaFailedToFetch += Triple(source, manga, e)
|
mangaFailedToFetch += Triple(source, manga, e)
|
||||||
}
|
}
|
||||||
@@ -161,7 +147,7 @@ class TestExtensionCompatibility {
|
|||||||
File("$BASE_PATH/MangaFailedToFetch.txt").writeText(
|
File("$BASE_PATH/MangaFailedToFetch.txt").writeText(
|
||||||
mangaFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
mangaFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
||||||
"${source.name} (${source.lang}, ${source.id}):" +
|
"${source.name} (${source.lang}, ${source.id}):" +
|
||||||
" ${manga.title} (${source.getMangaUrl(manga)}):" +
|
" ${manga.title} (${source.mangaDetailsRequest(manga).url}):" +
|
||||||
" ${exception.message}"
|
" ${exception.message}"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -177,9 +163,9 @@ class TestExtensionCompatibility {
|
|||||||
repeat { source.getPageList(chapter) }
|
repeat { source.getPageList(chapter) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn {
|
logger.warn {
|
||||||
"Failed to fetch manga info from $source for ${manga.title} (${source.getMangaUrl(
|
"Failed to fetch manga info from $source for ${manga.title} (${source.mangaDetailsRequest(
|
||||||
manga,
|
manga,
|
||||||
)}): ${e.message}"
|
).url}): ${e.message}"
|
||||||
}
|
}
|
||||||
chaptersPageListFailedToFetch += Triple(source, manga to chapter, e)
|
chaptersPageListFailedToFetch += Triple(source, manga to chapter, e)
|
||||||
}
|
}
|
||||||
@@ -190,7 +176,7 @@ class TestExtensionCompatibility {
|
|||||||
File("$BASE_PATH/ChapterPageListFailedToFetch.txt").writeText(
|
File("$BASE_PATH/ChapterPageListFailedToFetch.txt").writeText(
|
||||||
chaptersPageListFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
chaptersPageListFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
||||||
"${source.name} (${source.lang}, ${source.id}):" +
|
"${source.name} (${source.lang}, ${source.id}):" +
|
||||||
" ${manga.first.title} (${source.getMangaUrl(manga.first)}):" +
|
" ${manga.first.title} (${source.mangaDetailsRequest(manga.first).url}):" +
|
||||||
" ${manga.second.name} (${manga.second.url}): ${exception.message}"
|
" ${manga.second.name} (${manga.second.url}): ${exception.message}"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user