Commonize installExtension function

This commit is contained in:
Syer10
2026-07-13 13:25:41 -04:00
parent 2051c6d51d
commit 3d73836329

View File

@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension
* License, v. 2.0. If a copy of the MPL was not distributed with this * License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import android.content.pm.PackageInfo
import android.net.Uri import android.net.Uri
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
@@ -76,6 +77,15 @@ 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 {
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 { suspend fun installExtension(pkgName: String): String {
logger.debug { "Installing $pkgName" } logger.debug { "Installing $pkgName" }
val extension = val extension =
@@ -87,22 +97,22 @@ object Extension {
} ?: throw NullPointerException("Could not find extension for $pkgName") } ?: throw NullPointerException("Could not find extension for $pkgName")
val jarUrl = extension[ExtensionTable.jarUrl] val jarUrl = extension[ExtensionTable.jarUrl]
val apkUrl = extension[ExtensionTable.apkUrl] val apkUrl = extension[ExtensionTable.apkUrl]
val fetchToTemp: suspend (String) -> Path = { url ->
val name = Uri.parse(jarUrl).lastPathSegment!!
val savePath = Path(applicationDirs.tempRoot) / "extensions" / name
// download jar file
downloadExtension(url, savePath)
savePath
}
return when { return when {
jarUrl != null -> { jarUrl != null -> {
installJAR { fetchToTemp(jarUrl) } installExtension {
val jar = fetchExtensionFile(jarUrl)
val manifest = extractAndParseAndroidManifest(jar)
ExtensionPackage.Jar(jar, manifest)
}
} }
apkUrl != null -> { apkUrl != null -> {
installAPK { fetchToTemp(apkUrl) } installExtension {
val apk = fetchExtensionFile(apkUrl)
val packageInfo = getPackageInfo(apk)
ExtensionPackage.Apk(apk, packageInfo)
}
} }
else -> { else -> {
@@ -111,98 +121,213 @@ object Extension {
} }
} }
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, extensionName: String,
): String { ): String =
val copyToExtensionsRoot = { when {
val rootPath = Path(applicationDirs.tempRoot) / "extensions" extensionName.endsWith(".jar") -> {
val downloadedFile = rootPath.resolve(extensionName).normalize() installExtension(
check(downloadedFile.startsWith(rootPath) && downloadedFile.parent == rootPath) { true,
"File '$extensionName' is not a valid extension file" {
val jar = copyToExtensionsRoot(inputStream, extensionName)
val manifest = extractAndParseAndroidManifest(jar)
ExtensionPackage.Jar(jar, manifest)
},
)
} }
logger.debug { "Saving jar at $extensionName" }
// download jar file extensionName.endsWith(".apk") -> {
downloadedFile.createParentDirectories() installExtension(
downloadedFile.outputStream().buffered().use { out -> true,
inputStream.use { {
it.copyTo(out) val apk = copyToExtensionsRoot(inputStream, extensionName)
} val packageInfo = getPackageInfo(apk)
ExtensionPackage.Apk(apk, packageInfo)
},
)
}
else -> {
throw NullPointerException("Could not find extension type for $extensionName")
} }
downloadedFile
} }
return when { sealed class ExtensionPackage {
extensionName.endsWith(".jar") -> installJAR(true, copyToExtensionsRoot) abstract val file: Path
extensionName.endsWith(".apk") -> installAPK(true, copyToExtensionsRoot) abstract val metadata: PackageMetadata
else -> throw NullPointerException("Could not find extension type for $extensionName")
// 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
}
} }
} }
suspend fun installAPK( 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,
fetcher: suspend () -> Path, fetchPackage: suspend () -> ExtensionPackage,
): String { ): String {
val apkFile = fetcher() val extPackage = fetchPackage()
val metadata = extPackage.metadata
val pkgName = metadata.packageName
val packageInfo = getPackageInfo(apkFile)
val pkgName = packageInfo.packageName
// check if we don't have the extension already installed
// if it's installed and we want to update, it first has to be uninstalled
val isInstalled = val isInstalled =
transaction { transaction {
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 extensionsRoot = Path(applicationDirs.extensionsRoot) if (isInstalled) {
val jarFile = extensionsRoot / (apkFile.nameWithoutExtension + ".jar") if (forceReinstall) {
uninstallExtension(pkgName)
if (isInstalled && forceReinstall) { } else {
uninstallExtension(pkgName) extPackage.file.deleteExisting()
} else if (isInstalled) { return pkgName
apkFile.deleteExisting() }
return pkgName
} }
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) { if (!metadata.reqFeatures.contains(EXTENSION_FEATURE)) {
throw Exception("This apk is not a Tachiyomi extension") extPackage.file.deleteExisting()
throw Exception("This file is not a Tachiyomi extension")
} }
// Validate lib version val libVersion = metadata.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) {
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",
) )
} }
// TODO: allow trusting keys var contentWarning =
// val signatureHash = getSignatureHash(packageInfo) extPackage.metadata
// .metaData
// if (signatureHash == null) { .getInt(METADATA_CONTENT_WARNING)
// 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 = packageInfo.applicationInfo.metaData contentWarning = extPackage.metadata
.metaData
.getString(METADATA_CONTENT_WARNING) .getString(METADATA_CONTENT_WARNING)
?.toIntOrNull() ?.toIntOrNull() ?: 0
?: 0
if (contentWarning == 0) { if (contentWarning == 0) {
contentWarning = packageInfo.applicationInfo.metaData contentWarning = extPackage.metadata
.metaData
.getString(METADATA_NSFW) .getString(METADATA_NSFW)
?.toIntOrNull() ?.toIntOrNull() ?: 0
?: 0
} }
} }
val sourceClass = val sourceClass =
packageInfo.applicationInfo.metaData metadata.metaData
.getString(METADATA_SOURCE_CLASS)!! .getString(METADATA_SOURCE_CLASS)!!
.trim() .trim()
@@ -215,36 +340,37 @@ object Extension {
logger.debug { "Main class for extension is $className" } logger.debug { "Main class for extension is $className" }
dex2jar(apkFile, jarFile) val extensionsRoot = Path(applicationDirs.extensionsRoot)
extractAssetsFromApk(apkFile, jarFile) val jarFile = extPackage.prepareJarAndIcons(extensionsRoot)
extractAndCacheApkIcon(apkFile, pkgName)
// clean up
apkFile.deleteExisting()
try { try {
val extensionName = val extensionName =
packageInfo.applicationInfo.metaData.getString(METADATA_NAME) metadata.metaData.getString(METADATA_NAME)
?: packageInfo.applicationInfo.nonLocalizedLabel ?: metadata.label?.substringAfter("Tachiyomi: ")
.toString() ?: throw Exception("Could not resolve extension name")
.substringAfter("Tachiyomi: ")
val extensionLibVersion = val extensionLibVersion =
packageInfo.applicationInfo.metaData metadata.metaData
.getString(METADATA_EXTENSION_LIB) .getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" } .takeUnless { it == "0" }
?: packageInfo.versionName.substringBeforeLast('.') ?: metadata.versionName.substringBeforeLast('.')
val apkName =
when (extPackage) {
is ExtensionPackage.Apk -> extPackage.file.name
is ExtensionPackage.Jar -> jarFile.name.removeSuffix(".jar") + ".apk"
}
setupJar( setupJar(
jarFile, jarFile = jarFile,
className, className = className,
extensionName, extensionName = extensionName,
extensionLibVersion, extensionLibVersion = extensionLibVersion,
apkFile.name, apkName = apkName,
pkgName, pkgName = pkgName,
packageInfo.versionName, versionName = metadata.versionName,
packageInfo.versionCode, versionCode = metadata.versionCode,
contentWarning, contentWarning = contentWarning,
) )
} catch (e: Throwable) { } catch (e: Throwable) {
// free up the file descriptor if exists // free up the file descriptor if exists
@@ -260,158 +386,6 @@ object Extension {
return pkgName return pkgName
} }
suspend fun installJAR(
forceReinstall: Boolean = false,
fetcher: suspend () -> Path,
): String {
val tmpJarFile = fetcher()
val jarFile = Path(applicationDirs.extensionsRoot) / tmpJarFile.name
val tmpIcon = jarFile.parent / (jarFile.nameWithoutExtension + ".icon")
val jarZip =
ZipFile
.builder()
.setPath(tmpJarFile)
.get()
val manifest: AndroidManifestParser.AndroidManifest
jarZip.use { jarZip ->
manifest =
jarZip
.getInputStream(jarZip.getEntry("AndroidManifest.xml"))
.use {
AndroidManifestParser.parse(it)
}
try {
ResourceArscIconParser.extractIcon(jarZip).use {
tmpIcon.outputStream().use { out ->
it.copyTo(out)
}
}
} catch (e: Exception) {
logger.warn(e) { "Failed to extract icon from JAR ${manifest.packageName}" }
}
}
val pkgName: String = manifest.packageName
// check if we don't have the extension already installed
// if it's installed and we want to update, it first has to be uninstalled
val isInstalled =
transaction {
ExtensionTable
.select(ExtensionTable.isInstalled)
.where { ExtensionTable.pkgName eq pkgName }
.firstOrNull()
}?.get(ExtensionTable.isInstalled) ?: false
if (isInstalled && forceReinstall) {
uninstallExtension(pkgName)
} else if (isInstalled) {
tmpJarFile.deleteExisting()
tmpIcon.deleteIfExists()
return pkgName
}
if (!manifest.usesFeatures.any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
// Validate lib version
val libVersion = manifest.versionName!!.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed",
)
}
if (tmpIcon.exists()) {
try {
cacheIcon(pkgName, tmpIcon.inputStream())
} catch (e: Exception) {
logger.warn(e) { "Failed to copy icon from JAR ${manifest.packageName}" }
}
}
tmpIcon.deleteIfExists()
tmpJarFile.copyTo(jarFile)
tmpJarFile.deleteExisting()
// TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo)
//
// if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) {
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
fun List<AndroidManifestParser.MetaData>.getString(name: String): String? = this.find { it.name == name }?.value
var contentWarning =
manifest.application!!
.metaData
.getString(METADATA_CONTENT_WARNING)
?.toIntOrNull()
if (contentWarning == null) {
contentWarning = manifest.application.metaData
.getString(METADATA_NSFW)
?.toIntOrNull()
?: 0
}
val sourceClass =
manifest.application.metaData
.getString(METADATA_SOURCE_CLASS)!!
.trim()
val className =
if (sourceClass.startsWith(".")) {
pkgName + sourceClass
} else {
sourceClass
}
logger.debug { "Main class for extension is $className" }
try {
val extensionName =
manifest.application.metaData.getString(METADATA_NAME)
?: manifest.application.label!!
.substringAfter("Tachiyomi: ")
val extensionLibVersion =
manifest.application.metaData
.getString(METADATA_EXTENSION_LIB)
.takeUnless { it == "0" }
?: manifest.versionName!!.substringBeforeLast('.')
setupJar(
jarFile,
className,
extensionName,
extensionLibVersion,
jarFile.name.removeSuffix(".jar") + ".apk",
pkgName,
manifest.versionName!!,
manifest.versionCode!!,
contentWarning,
)
} catch (e: Throwable) {
// free up the file descriptor if exists
PackageTools.jarLoaderMap.remove(jarFile.absolutePathString())?.close()
jarFile.deleteIfExists()
try {
uninstallExtension(pkgName)
} catch (_: Exception) {
}
throw e
}
return pkgName
}
private fun setupJar( private fun setupJar(
jarFile: Path, jarFile: Path,
className: String, className: String,
@@ -579,6 +553,13 @@ object Extension {
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 downloadExtension(