Improvements and fixes

This commit is contained in:
Syer10
2026-07-12 23:43:11 -04:00
parent c0586e7f36
commit a830782b4b

View File

@@ -17,9 +17,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import net.dongliu.apk.parser.ApkFile import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.bean.Icon import net.dongliu.apk.parser.bean.Icon
import okhttp3.CacheControl import okhttp3.CacheControl
import okio.buffer
import okio.sink
import okio.source
import org.apache.commons.compress.archivers.zip.ZipFile import org.apache.commons.compress.archivers.zip.ZipFile
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
@@ -61,6 +58,8 @@ import kotlin.io.path.Path
import kotlin.io.path.absolutePathString import kotlin.io.path.absolutePathString
import kotlin.io.path.copyTo import kotlin.io.path.copyTo
import kotlin.io.path.createDirectories import kotlin.io.path.createDirectories
import kotlin.io.path.createParentDirectories
import kotlin.io.path.deleteExisting
import kotlin.io.path.deleteIfExists import kotlin.io.path.deleteIfExists
import kotlin.io.path.deleteRecursively import kotlin.io.path.deleteRecursively
import kotlin.io.path.div import kotlin.io.path.div
@@ -92,7 +91,7 @@ object Extension {
jarUrl != null -> { jarUrl != null -> {
installJAR { installJAR {
val jarName = Uri.parse(jarUrl).lastPathSegment!! val jarName = Uri.parse(jarUrl).lastPathSegment!!
val jarSavePath = "${applicationDirs.extensionsRoot}/$jarName" val jarSavePath = Path(applicationDirs.tempRoot) / "extensions" / jarName
// download jar file // download jar file
downloadExtension(jarUrl, jarSavePath) downloadExtension(jarUrl, jarSavePath)
@@ -103,7 +102,7 @@ object Extension {
apkUrl != null -> { apkUrl != null -> {
installAPK { installAPK {
val apkName = Uri.parse(apkUrl).lastPathSegment!! val apkName = Uri.parse(apkUrl).lastPathSegment!!
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName" val apkSavePath = Path(applicationDirs.tempRoot) / "extensions" / apkName
// download apk file // download apk file
downloadExtension(apkUrl, apkSavePath) downloadExtension(apkUrl, apkSavePath)
@@ -122,20 +121,20 @@ object Extension {
extensionName: String, extensionName: String,
): String { ): String {
val copyToExtensionsRoot = { val copyToExtensionsRoot = {
val rootPath = Path(applicationDirs.extensionsRoot) val rootPath = Path(applicationDirs.tempRoot) / "extensions"
val downloadedFile = rootPath.resolve(extensionName).normalize() val downloadedFile = rootPath.resolve(extensionName).normalize()
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) { check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) {
"File '$extensionName' is not a valid extension file" "File '$extensionName' is not a valid extension file"
} }
logger.debug { "Saving jar at $extensionName" } logger.debug { "Saving jar at $extensionName" }
// download jar file // download jar file
downloadedFile.outputStream().sink().buffer().use { sink -> downloadedFile.createParentDirectories()
inputStream.source().use { source -> downloadedFile.outputStream().buffered().use { out ->
sink.writeAll(source) inputStream.use {
sink.flush() it.copyTo(out)
} }
} }
downloadedFile.absolutePathString() downloadedFile
} }
return when { return when {
@@ -147,9 +146,9 @@ object Extension {
suspend fun installAPK( suspend fun installAPK(
forceReinstall: Boolean = false, forceReinstall: Boolean = false,
fetcher: suspend () -> String, fetcher: suspend () -> Path,
): String { ): String {
val apkFile = Path(fetcher()) val apkFile = fetcher()
val packageInfo = getPackageInfo(apkFile) val packageInfo = getPackageInfo(apkFile)
val pkgName = packageInfo.packageName val pkgName = packageInfo.packageName
@@ -161,232 +160,261 @@ object Extension {
ExtensionTable.select(ExtensionTable.isInstalled).where { ExtensionTable.pkgName eq pkgName }.firstOrNull() ExtensionTable.select(ExtensionTable.isInstalled).where { ExtensionTable.pkgName eq pkgName }.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false }?.get(ExtensionTable.isInstalled) ?: false
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/${apkFile.nameWithoutExtension}" val extensionsRoot = Path(applicationDirs.extensionsRoot)
val jarFile = Path("$dirPathWithoutType.jar") val jarFile = extensionsRoot / (apkFile.nameWithoutExtension + ".jar")
if (isInstalled && forceReinstall) { if (isInstalled && forceReinstall) {
uninstallExtension(pkgName) uninstallExtension(pkgName)
} else if (isInstalled) {
apkFile.deleteExisting()
return pkgName
} }
if (!isInstalled || forceReinstall) { if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) { throw Exception("This apk is not a Tachiyomi extension")
throw Exception("This apk is not a Tachiyomi extension") }
}
// Validate lib version // Validate lib version
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble() val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
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",
) )
} }
// TODO: allow trusting keys // TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo) // val signatureHash = getSignatureHash(packageInfo)
// //
// if (signatureHash == null) { // if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed") // throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) { // } else if (signatureHash !in trustedSignatures) {
// throw Exception("This apk is not a signed with the official tachiyomi signature") // throw Exception("This apk is not a signed with the official tachiyomi signature")
// } // }
var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING) var contentWarning = packageInfo.applicationInfo.metaData.getInt(METADATA_CONTENT_WARNING)
if (contentWarning == 0) {
contentWarning = packageInfo.applicationInfo.metaData
.getString(METADATA_CONTENT_WARNING)
?.toIntOrNull()
?: 0
if (contentWarning == 0) { if (contentWarning == 0) {
contentWarning = packageInfo.applicationInfo.metaData contentWarning = packageInfo.applicationInfo.metaData
.getString(METADATA_CONTENT_WARNING) .getString(METADATA_NSFW)
?.toIntOrNull() ?.toIntOrNull()
?: 0 ?: 0
if (contentWarning == 0) { }
contentWarning = packageInfo.applicationInfo.metaData }
.getString(METADATA_NSFW)
?.toIntOrNull() val sourceClass =
?: 0 packageInfo.applicationInfo.metaData
} .getString(METADATA_SOURCE_CLASS)!!
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
} }
val sourceClass = logger.debug { "Main class for extension is $className" }
dex2jar(apkFile, jarFile)
extractAssetsFromApk(apkFile, jarFile)
extractAndCacheApkIcon(apkFile, pkgName)
// clean up
apkFile.deleteExisting()
try {
val extensionName =
packageInfo.applicationInfo.metaData.getString(METADATA_NAME)
?: packageInfo.applicationInfo.nonLocalizedLabel
.toString()
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
packageInfo.applicationInfo.metaData packageInfo.applicationInfo.metaData
.getString(METADATA_SOURCE_CLASS)!! .getString(METADATA_EXTENSION_LIB)
.trim() .takeUnless { it == "0" }
?: packageInfo.versionName.substringBeforeLast('.')
val className = setupJar(
if (sourceClass.startsWith(".")) { jarFile,
pkgName + sourceClass className,
} else { extensionName,
sourceClass extensionLibVersion,
} apkFile.name,
pkgName,
logger.debug { "Main class for extension is $className" } packageInfo.versionName,
packageInfo.versionCode,
dex2jar(apkFile, jarFile) contentWarning,
extractAssetsFromApk(apkFile, jarFile) )
extractAndCacheApkIcon(apkFile, pkgName) } catch (e: Throwable) {
// free up the file descriptor if exists
// clean up PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
apkFile.deleteIfExists() jarFile.deleteIfExists()
try { try {
val extensionName = uninstallExtension(pkgName)
packageInfo.applicationInfo.metaData.getString(METADATA_NAME) } catch (_: Throwable) {
?: packageInfo.applicationInfo.nonLocalizedLabel
.toString()
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
packageInfo.applicationInfo.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: packageInfo.versionName.substringBeforeLast('.')
setupJar(
jarFile,
className,
extensionName,
extensionLibVersion,
apkFile.name,
pkgName,
packageInfo.versionName,
packageInfo.versionCode,
contentWarning,
)
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
jarFile.deleteIfExists()
try {
uninstallExtension(pkgName)
} catch (_: Throwable) {
}
throw e
} }
throw e
} }
return pkgName return pkgName
} }
suspend fun installJAR( suspend fun installJAR(
forceReinstall: Boolean = false, forceReinstall: Boolean = false,
fetcher: suspend () -> String, fetcher: suspend () -> Path,
): String { ): String {
val jarFile = Path(fetcher()) val tmpJarFile = fetcher()
val jarFile = Path(applicationDirs.extensionsRoot) / tmpJarFile.name
val tmpIcon = jarFile.parent / (jarFile.nameWithoutExtension + ".icon")
val jarZip = val jarZip =
ZipFile ZipFile
.builder() .builder()
.setPath(jarFile) .setPath(tmpJarFile)
.get() .get()
return jarZip.use { jarZip -> val manifest: AndroidManifestParser.AndroidManifest
val manifest = jarZip.use { jarZip ->
manifest =
jarZip jarZip
.getInputStream(jarZip.getEntry("AndroidManifest.xml")) .getInputStream(jarZip.getEntry("AndroidManifest.xml"))
.use { .use {
AndroidManifestParser.parse(it) AndroidManifestParser.parse(it)
} }
val pkgName = manifest.packageName try {
ResourceArscIconParser.extractIcon(jarZip).use {
// check if we don't have the extension already installed tmpIcon.outputStream().use { out ->
// if it's installed and we want to update, it first has to be uninstalled it.copyTo(out)
val isInstalled =
transaction {
ExtensionTable.select(ExtensionTable.isInstalled).where { ExtensionTable.pkgName eq pkgName }.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false
if (isInstalled && forceReinstall) {
uninstallExtension(pkgName)
}
if (!isInstalled || forceReinstall) {
if (!manifest.usesFeatures.any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
// Validate lib version
val libVersion = manifest.versionName!!.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
)
}
// TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo)
//
// if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) {
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
fun List<AndroidManifestParser.MetaData>.getString(name: String): String? = this.find { it.name == name }?.value
var contentWarning =
manifest.application!!
.metaData
.getString(METADATA_CONTENT_WARNING)
?.toIntOrNull()
if (contentWarning == null) {
contentWarning = manifest.application.metaData
.getString(METADATA_NSFW)
?.toIntOrNull()
?: 0
}
val sourceClass =
manifest.application.metaData
.getString(METADATA_SOURCE_CLASS)!!
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
} }
logger.debug { "Main class for extension is $className" }
extractAndCacheJarIcon(jarZip, pkgName)
try {
val extensionName =
manifest.application.metaData.getString(METADATA_NAME)
?: manifest.application.label!!
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
manifest.application.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: manifest.versionName.substringBeforeLast('.')
setupJar(
jarFile,
className,
extensionName,
extensionLibVersion,
jarFile.name.removeSuffix(".jar") + ".apk",
pkgName,
manifest.versionName,
manifest.versionCode!!,
contentWarning,
)
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
jarFile.deleteIfExists()
try {
uninstallExtension(pkgName)
} catch (_: Exception) {
}
throw e
} }
} catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from JAR ${manifest.packageName}" }
} }
return@use pkgName
} }
val pkgName: String = manifest.packageName
// check if we don't have the extension already installed
// if it's installed and we want to update, it first has to be uninstalled
val isInstalled =
transaction {
ExtensionTable
.select(ExtensionTable.isInstalled)
.where { ExtensionTable.pkgName eq pkgName }
.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false
if (isInstalled && forceReinstall) {
uninstallExtension(pkgName)
} else if (isInstalled) {
tmpJarFile.deleteExisting()
tmpIcon.deleteIfExists()
return pkgName
}
if (!manifest.usesFeatures.any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
// Validate lib version
val libVersion = manifest.versionName!!.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
)
}
if (tmpIcon.exists()) {
try {
cacheIcon(pkgName, tmpIcon.inputStream())
} catch (e: Exception) {
logger.warn(e) { "Failed to copy icon from JAR ${manifest.packageName}" }
}
}
tmpIcon.deleteIfExists()
tmpJarFile.copyTo(jarFile)
tmpJarFile.deleteExisting()
// TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo)
//
// if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) {
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
fun List<AndroidManifestParser.MetaData>.getString(name: String): String? = this.find { it.name == name }?.value
var contentWarning =
manifest.application!!
.metaData
.getString(METADATA_CONTENT_WARNING)
?.toIntOrNull()
if (contentWarning == null) {
contentWarning = manifest.application.metaData
.getString(METADATA_NSFW)
?.toIntOrNull()
?: 0
}
val sourceClass =
manifest.application.metaData
.getString(METADATA_SOURCE_CLASS)!!
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
}
logger.debug { "Main class for extension is $className" }
try {
val extensionName =
manifest.application.metaData.getString(METADATA_NAME)
?: manifest.application.label!!
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
manifest.application.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: manifest.versionName!!.substringBeforeLast('.')
setupJar(
jarFile,
className,
extensionName,
extensionLibVersion,
jarFile.name.removeSuffix(".jar") + ".apk",
pkgName,
manifest.versionName!!,
manifest.versionCode!!,
contentWarning,
)
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
jarFile.deleteIfExists()
try {
uninstallExtension(pkgName)
} catch (_: Exception) {
}
throw e
}
return pkgName
} }
private fun setupJar( private fun setupJar(
@@ -499,7 +527,7 @@ object Extension {
pkgName: String, pkgName: String,
inputStream: InputStream, inputStream: InputStream,
) { ) {
val iconCacheDir = Path("${applicationDirs.extensionsRoot}/icon") val iconCacheDir = Path(applicationDirs.extensionsRoot) / "icon"
iconCacheDir.createDirectories() iconCacheDir.createDirectories()
clearCachedImage(iconCacheDir.absolutePathString(), pkgName) clearCachedImage(iconCacheDir.absolutePathString(), pkgName)
saveImage("$iconCacheDir/$pkgName", inputStream, null) saveImage("$iconCacheDir/$pkgName", inputStream, null)
@@ -517,7 +545,7 @@ object Extension {
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 = assetsFolder / zipEntry.name
assetFile.parent.createDirectories() assetFile.createParentDirectories()
assetFile.outputStream().use { outputStream -> assetFile.outputStream().use { outputStream ->
zipInputStream.copyTo(outputStream) zipInputStream.copyTo(outputStream)
} }
@@ -551,7 +579,7 @@ object Extension {
jarFile.deleteIfExists() jarFile.deleteIfExists()
tempJarFile.copyTo(jarFile) tempJarFile.copyTo(jarFile)
tempJarFile.deleteIfExists() tempJarFile.deleteExisting()
assetsFolder.deleteRecursively() assetsFolder.deleteRecursively()
} }
@@ -560,7 +588,7 @@ object Extension {
private suspend fun downloadExtension( private suspend fun downloadExtension(
url: String, url: String,
savePath: String, savePath: Path,
) { ) {
val response = val response =
network.client network.client
@@ -568,9 +596,9 @@ object Extension {
GET(url, cache = CacheControl.FORCE_NETWORK), GET(url, cache = CacheControl.FORCE_NETWORK),
).await() ).await()
val downloadedFile = Path(savePath) savePath.createParentDirectories()
response.body.byteStream().use { response.body.byteStream().use {
downloadedFile.outputStream().buffered().use { out -> savePath.outputStream().buffered().use { out ->
it.copyTo(out) it.copyTo(out)
} }
} }
@@ -612,7 +640,7 @@ object Extension {
// clear all loaded sources // clear all loaded sources
sources.forEach { GetSource.unregisterSource(it) } sources.forEach { GetSource.unregisterSource(it) }
jarPath.deleteIfExists() jarPath.deleteExisting()
} }
} }