mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-02 10:24:35 -05:00
252 lines
10 KiB
Kotlin
252 lines
10 KiB
Kotlin
package suwayomi.anime.impl.extension
|
|
|
|
/*
|
|
* 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 android.net.Uri
|
|
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
|
|
import eu.kanade.tachiyomi.animesource.AnimeSource
|
|
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
|
|
import eu.kanade.tachiyomi.network.GET
|
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
|
import mu.KotlinLogging
|
|
import okhttp3.Request
|
|
import okio.buffer
|
|
import okio.sink
|
|
import org.jetbrains.exposed.sql.deleteWhere
|
|
import org.jetbrains.exposed.sql.insert
|
|
import org.jetbrains.exposed.sql.select
|
|
import org.jetbrains.exposed.sql.transactions.transaction
|
|
import org.jetbrains.exposed.sql.update
|
|
import org.kodein.di.DI
|
|
import org.kodein.di.conf.global
|
|
import org.kodein.di.instance
|
|
import suwayomi.anime.impl.extension.ExtensionsList.extensionTableAsDataClass
|
|
import suwayomi.anime.impl.extension.github.ExtensionGithubApi
|
|
import suwayomi.anime.impl.util.PackageTools.EXTENSION_FEATURE
|
|
import suwayomi.anime.impl.util.PackageTools.LIB_VERSION_MAX
|
|
import suwayomi.anime.impl.util.PackageTools.LIB_VERSION_MIN
|
|
import suwayomi.anime.impl.util.PackageTools.METADATA_NSFW
|
|
import suwayomi.anime.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
|
import suwayomi.anime.impl.util.PackageTools.dex2jar
|
|
import suwayomi.anime.impl.util.PackageTools.getPackageInfo
|
|
import suwayomi.anime.impl.util.PackageTools.getSignatureHash
|
|
import suwayomi.anime.impl.util.PackageTools.loadExtensionSources
|
|
import suwayomi.anime.impl.util.PackageTools.trustedSignatures
|
|
import suwayomi.anime.model.table.AnimeExtensionTable
|
|
import suwayomi.anime.model.table.AnimeSourceTable
|
|
import suwayomi.server.ApplicationDirs
|
|
import suwayomi.tachidesk.impl.util.network.await
|
|
import suwayomi.tachidesk.impl.util.storage.CachedImageResponse.getCachedImageResponse
|
|
import uy.kohesive.injekt.injectLazy
|
|
import java.io.File
|
|
import java.io.InputStream
|
|
|
|
object Extension {
|
|
private val logger = KotlinLogging.logger {}
|
|
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
|
|
|
data class InstallableAPK(
|
|
val apkFilePath: String,
|
|
val pkgName: String
|
|
)
|
|
|
|
suspend fun installExtension(pkgName: String): Int {
|
|
logger.debug("Installing $pkgName")
|
|
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
|
|
|
|
return installAPK {
|
|
val apkURL = ExtensionGithubApi.getApkUrl(extensionRecord)
|
|
val apkName = Uri.parse(apkURL).lastPathSegment!!
|
|
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
|
|
// download apk file
|
|
downloadAPKFile(apkURL, apkSavePath)
|
|
|
|
apkSavePath
|
|
}
|
|
}
|
|
|
|
suspend fun installAPK(fetcher: suspend () -> String): Int {
|
|
val apkFilePath = fetcher()
|
|
val apkName = File(apkFilePath).name
|
|
|
|
// check if we don't have the extension already installed
|
|
// if it's installed and we want to update, it first has to be uninstalled
|
|
val isInstalled = transaction {
|
|
AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.firstOrNull()
|
|
}?.get(AnimeExtensionTable.isInstalled) ?: false
|
|
|
|
if (!isInstalled) {
|
|
val fileNameWithoutType = apkName.substringBefore(".apk")
|
|
|
|
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
|
|
val jarFilePath = "$dirPathWithoutType.jar"
|
|
val dexFilePath = "$dirPathWithoutType.dex"
|
|
|
|
val packageInfo = getPackageInfo(apkFilePath)
|
|
val pkgName = packageInfo.packageName
|
|
|
|
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
|
|
throw Exception("This apk is not a Tachiyomi extension")
|
|
}
|
|
|
|
// Validate lib version
|
|
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
|
|
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
|
throw Exception(
|
|
"Lib version is $libVersion, while only versions " +
|
|
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
|
|
)
|
|
}
|
|
|
|
val signatureHash = getSignatureHash(packageInfo)
|
|
|
|
if (signatureHash == null) {
|
|
throw Exception("Package $pkgName isn't signed")
|
|
} else if (signatureHash !in trustedSignatures) {
|
|
// TODO: allow trusting keys
|
|
throw Exception("This apk is not a signed with the official tachiyomi signature")
|
|
}
|
|
|
|
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
|
|
|
|
val className = packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
|
|
|
logger.debug("Main class for extension is $className")
|
|
|
|
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
|
|
|
// clean up
|
|
// File(apkFilePath).delete()
|
|
File(dexFilePath).delete()
|
|
|
|
// collect sources from the extension
|
|
val sources: List<AnimeCatalogueSource> = when (val instance = loadExtensionSources(jarFilePath, className)) {
|
|
is AnimeSource -> listOf(instance)
|
|
is AnimeSourceFactory -> instance.createSources()
|
|
else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}")
|
|
}.map { it as AnimeCatalogueSource }
|
|
|
|
val langs = sources.map { it.lang }.toSet()
|
|
val extensionLang = when (langs.size) {
|
|
0 -> ""
|
|
1 -> langs.first()
|
|
else -> "all"
|
|
}
|
|
|
|
val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Tachiyomi: ")
|
|
|
|
// update extension info
|
|
transaction {
|
|
if (AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
|
|
AnimeExtensionTable.insert {
|
|
it[this.apkName] = apkName
|
|
it[name] = extensionName
|
|
it[this.pkgName] = packageInfo.packageName
|
|
it[versionName] = packageInfo.versionName
|
|
it[versionCode] = packageInfo.versionCode
|
|
it[lang] = extensionLang
|
|
it[this.isNsfw] = isNsfw
|
|
}
|
|
}
|
|
|
|
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
|
|
it[this.isInstalled] = true
|
|
it[this.classFQName] = className
|
|
}
|
|
|
|
val extensionId = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first()[AnimeExtensionTable.id].value
|
|
|
|
sources.forEach { httpSource ->
|
|
AnimeSourceTable.insert {
|
|
it[id] = httpSource.id
|
|
it[name] = httpSource.name
|
|
it[lang] = httpSource.lang
|
|
it[extension] = extensionId
|
|
}
|
|
logger.debug("Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}")
|
|
}
|
|
}
|
|
return 201 // we installed successfully
|
|
} else {
|
|
return 302 // extension was already installed
|
|
}
|
|
}
|
|
|
|
private val network: NetworkHelper by injectLazy()
|
|
|
|
private suspend fun downloadAPKFile(url: String, savePath: String) {
|
|
val request = Request.Builder().url(url).build()
|
|
val response = network.client.newCall(request).await()
|
|
|
|
val downloadedFile = File(savePath)
|
|
downloadedFile.sink().buffer().use { sink ->
|
|
response.body!!.source().use { source ->
|
|
sink.writeAll(source)
|
|
sink.flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
fun uninstallExtension(pkgName: String) {
|
|
logger.debug("Uninstalling $pkgName")
|
|
|
|
val extensionRecord = transaction { AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first() }
|
|
val fileNameWithoutType = extensionRecord[AnimeExtensionTable.apkName].substringBefore(".apk")
|
|
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
|
transaction {
|
|
val extensionId = extensionRecord[AnimeExtensionTable.id].value
|
|
|
|
AnimeSourceTable.deleteWhere { AnimeSourceTable.extension eq extensionId }
|
|
if (extensionRecord[AnimeExtensionTable.isObsolete])
|
|
AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq pkgName }
|
|
else
|
|
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
|
|
it[isInstalled] = false
|
|
}
|
|
}
|
|
|
|
if (File(jarPath).exists()) {
|
|
File(jarPath).delete()
|
|
}
|
|
}
|
|
|
|
suspend fun updateExtension(pkgName: String): Int {
|
|
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
|
|
uninstallExtension(pkgName)
|
|
transaction {
|
|
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
|
|
it[name] = targetExtension.name
|
|
it[versionName] = targetExtension.versionName
|
|
it[versionCode] = targetExtension.versionCode
|
|
it[lang] = targetExtension.lang
|
|
it[isNsfw] = targetExtension.isNsfw
|
|
it[apkName] = targetExtension.apkName
|
|
it[iconUrl] = targetExtension.iconUrl
|
|
it[hasUpdate] = false
|
|
}
|
|
}
|
|
return installExtension(pkgName)
|
|
}
|
|
|
|
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
|
|
val iconUrl = transaction { AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.first() }[AnimeExtensionTable.iconUrl]
|
|
|
|
val saveDir = "${applicationDirs.extensionsRoot}/icon"
|
|
|
|
return getCachedImageResponse(saveDir, apkName) {
|
|
network.client.newCall(
|
|
GET(iconUrl)
|
|
).await()
|
|
}
|
|
}
|
|
|
|
fun getExtensionIconUrl(apkName: String): String {
|
|
return "/api/v1/anime/extension/icon/$apkName"
|
|
}
|
|
}
|