mirror of
https://github.com/tachiyomiorg/tachiyomi-extensions-inspector.git
synced 2025-01-25 23:11:17 +01:00
the new and improved apk installer
This commit is contained in:
parent
abc3a16ee3
commit
15bd5b4b7a
@ -36,12 +36,6 @@ dependencies {
|
|||||||
// Config API
|
// Config API
|
||||||
implementation(project(":AndroidCompat:Config"))
|
implementation(project(":AndroidCompat:Config"))
|
||||||
|
|
||||||
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
|
|
||||||
compileOnly("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
|
|
||||||
|
|
||||||
// APK parser
|
|
||||||
compileOnly("net.dongliu:apk-parser:2.6.10")
|
|
||||||
|
|
||||||
// APK sig verifier
|
// APK sig verifier
|
||||||
compileOnly("com.android.tools.build:apksig:4.2.0-alpha13")
|
compileOnly("com.android.tools.build:apksig:4.2.0-alpha13")
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ data class InstalledPackage(val root: File) {
|
|||||||
val icon = File(root, "icon.png")
|
val icon = File(root, "icon.png")
|
||||||
|
|
||||||
val info: PackageInfo
|
val info: PackageInfo
|
||||||
get() = ApkParsers.getMetaInfo(apk).toPackageInfo(root, apk).also {
|
get() = ApkParsers.getMetaInfo(apk).toPackageInfo(apk).also {
|
||||||
val parsed = ApkFile(apk)
|
val parsed = ApkFile(apk)
|
||||||
val dbFactory = DocumentBuilderFactory.newInstance()
|
val dbFactory = DocumentBuilderFactory.newInstance()
|
||||||
val dBuilder = dbFactory.newDocumentBuilder()
|
val dBuilder = dbFactory.newDocumentBuilder()
|
||||||
@ -82,12 +82,14 @@ data class InstalledPackage(val root: File) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NodeList.toList(): List<Node> {
|
companion object {
|
||||||
val out = mutableListOf<Node>()
|
fun NodeList.toList(): List<Node> {
|
||||||
|
val out = mutableListOf<Node>()
|
||||||
|
|
||||||
for(i in 0 until length)
|
for (i in 0 until length)
|
||||||
out += item(i)
|
out += item(i)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -6,7 +6,7 @@ import android.content.pm.PackageInfo
|
|||||||
import net.dongliu.apk.parser.bean.ApkMeta
|
import net.dongliu.apk.parser.bean.ApkMeta
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
fun ApkMeta.toPackageInfo(root: File, apk: File): PackageInfo {
|
fun ApkMeta.toPackageInfo(apk: File): PackageInfo {
|
||||||
return PackageInfo().also {
|
return PackageInfo().also {
|
||||||
it.packageName = packageName
|
it.packageName = packageName
|
||||||
it.versionCode = versionCode.toInt()
|
it.versionCode = versionCode.toInt()
|
||||||
|
@ -77,5 +77,11 @@ configure(projects) {
|
|||||||
|
|
||||||
// to get application content root
|
// to get application content root
|
||||||
implementation("net.harawata:appdirs:1.2.0")
|
implementation("net.harawata:appdirs:1.2.0")
|
||||||
|
|
||||||
|
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
|
||||||
|
implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
|
||||||
|
|
||||||
|
// APK parser
|
||||||
|
implementation("net.dongliu:apk-parser:2.6.10")
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -58,9 +58,6 @@ dependencies {
|
|||||||
implementation("org.jsoup:jsoup:1.13.1")
|
implementation("org.jsoup:jsoup:1.13.1")
|
||||||
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
|
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
|
||||||
|
|
||||||
// dex2jar: https://github.com/DexPatcher/dex2jar/releases/tag/v2.1-20190905-lanchon
|
|
||||||
implementation("com.github.DexPatcher.dex2jar:dex-tools:v2.1-20190905-lanchon")
|
|
||||||
|
|
||||||
|
|
||||||
// api
|
// api
|
||||||
implementation("io.javalin:javalin:3.12.0")
|
implementation("io.javalin:javalin:3.12.0")
|
||||||
|
44
server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/Hash.kt
Normal file
44
server/src/main/kotlin/eu/kanade/tachiyomi/util/lang/Hash.kt
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package eu.kanade.tachiyomi.util.lang
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
object Hash {
|
||||||
|
|
||||||
|
private val chars = charArrayOf(
|
||||||
|
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
|
||||||
|
'a', 'b', 'c', 'd', 'e', 'f'
|
||||||
|
)
|
||||||
|
|
||||||
|
private val MD5 get() = MessageDigest.getInstance("MD5")
|
||||||
|
|
||||||
|
private val SHA256 get() = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
fun sha256(bytes: ByteArray): String {
|
||||||
|
return encodeHex(SHA256.digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sha256(string: String): String {
|
||||||
|
return sha256(string.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun md5(bytes: ByteArray): String {
|
||||||
|
return encodeHex(MD5.digest(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun md5(string: String): String {
|
||||||
|
return md5(string.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeHex(data: ByteArray): String {
|
||||||
|
val l = data.size
|
||||||
|
val out = CharArray(l shl 1)
|
||||||
|
var i = 0
|
||||||
|
var j = 0
|
||||||
|
while (i < l) {
|
||||||
|
out[j++] = chars[(240 and data[i].toInt()).ushr(4)]
|
||||||
|
out[j++] = chars[15 and data[i].toInt()]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return String(out)
|
||||||
|
}
|
||||||
|
}
|
@ -8,17 +8,24 @@ package ir.armor.tachidesk.impl
|
|||||||
* 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.net.Uri
|
import android.net.Uri
|
||||||
import com.googlecode.d2j.dex.Dex2jar
|
|
||||||
import com.googlecode.d2j.reader.MultiDexFileReader
|
|
||||||
import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler
|
|
||||||
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
|
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
|
||||||
import ir.armor.tachidesk.impl.ExtensionsList.extensionTableAsDataClass
|
import ir.armor.tachidesk.impl.ExtensionsList.extensionTableAsDataClass
|
||||||
import ir.armor.tachidesk.impl.util.APKExtractor
|
|
||||||
import ir.armor.tachidesk.impl.util.CachedImageResponse.getCachedImageResponse
|
import ir.armor.tachidesk.impl.util.CachedImageResponse.getCachedImageResponse
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.EXTENSION_FEATURE
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.LIB_VERSION_MAX
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.LIB_VERSION_MIN
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.METADATA_NSFW
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.METADATA_SOURCE_CLASS
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.dex2jar
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.getPackageInfo
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.getSignatureHash
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.loadExtensionSources
|
||||||
|
import ir.armor.tachidesk.impl.util.PackageTools.trustedSignatures
|
||||||
import ir.armor.tachidesk.impl.util.await
|
import ir.armor.tachidesk.impl.util.await
|
||||||
import ir.armor.tachidesk.model.database.ExtensionTable
|
import ir.armor.tachidesk.model.database.ExtensionTable
|
||||||
import ir.armor.tachidesk.model.database.SourceTable
|
import ir.armor.tachidesk.model.database.SourceTable
|
||||||
@ -27,7 +34,6 @@ import mu.KotlinLogging
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.sink
|
import okio.sink
|
||||||
import org.jetbrains.exposed.sql.ResultRow
|
|
||||||
import org.jetbrains.exposed.sql.deleteWhere
|
import org.jetbrains.exposed.sql.deleteWhere
|
||||||
import org.jetbrains.exposed.sql.insert
|
import org.jetbrains.exposed.sql.insert
|
||||||
import org.jetbrains.exposed.sql.select
|
import org.jetbrains.exposed.sql.select
|
||||||
@ -36,59 +42,10 @@ import org.jetbrains.exposed.sql.update
|
|||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.net.URL
|
|
||||||
import java.net.URLClassLoader
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.nio.file.Path
|
|
||||||
|
|
||||||
object Extension {
|
object Extension {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert dex to jar, a wrapper for the dex2jar library
|
|
||||||
*/
|
|
||||||
private fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) {
|
|
||||||
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
|
|
||||||
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
|
|
||||||
|
|
||||||
val jarFilePath = File(jarFile).toPath()
|
|
||||||
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
|
|
||||||
val handler = BaksmaliBaseDexExceptionHandler()
|
|
||||||
Dex2jar
|
|
||||||
.from(reader)
|
|
||||||
.withExceptionHandler(handler)
|
|
||||||
.reUseReg(false)
|
|
||||||
.topoLogicalSort()
|
|
||||||
.skipDebug(true)
|
|
||||||
.optimizeSynchronized(false)
|
|
||||||
.printIR(false)
|
|
||||||
.noCode(false)
|
|
||||||
.skipExceptions(false)
|
|
||||||
.to(jarFilePath)
|
|
||||||
if (handler.hasException()) {
|
|
||||||
val errorFile: Path = File(ApplicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt")
|
|
||||||
logger.error(
|
|
||||||
"Detail Error Information in File $errorFile\n" +
|
|
||||||
"Please report this file to one of following link if possible (any one).\n" +
|
|
||||||
" https://sourceforge.net/p/dex2jar/tickets/\n" +
|
|
||||||
" https://bitbucket.org/pxb1988/dex2jar/issues\n" +
|
|
||||||
" https://github.com/pxb1988/dex2jar/issues\n" +
|
|
||||||
" dex2jar@googlegroups.com"
|
|
||||||
)
|
|
||||||
handler.dump(errorFile, emptyArray<String>())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* loads the extension main class called $className from the jar located at $jarPath
|
|
||||||
* It may return an instance of HttpSource or SourceFactory depending on the extension.
|
|
||||||
*/
|
|
||||||
fun loadExtensionInstance(jarPath: String, className: String): Any {
|
|
||||||
val classLoader = URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
|
|
||||||
val classToLoad = Class.forName(className, true, classLoader)
|
|
||||||
return classToLoad.getDeclaredConstructor().newInstance()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class InstallableAPK(
|
data class InstallableAPK(
|
||||||
val apkFilePath: String,
|
val apkFilePath: String,
|
||||||
val pkgName: String
|
val pkgName: String
|
||||||
@ -113,82 +70,104 @@ object Extension {
|
|||||||
val apkFilePath = fetcher()
|
val apkFilePath = fetcher()
|
||||||
val apkName = Uri.parse(apkFilePath).lastPathSegment!!
|
val apkName = Uri.parse(apkFilePath).lastPathSegment!!
|
||||||
|
|
||||||
// TODO: handle the whole apk signature, and trusting business
|
|
||||||
|
|
||||||
val extensionRecord: ResultRow = transaction {
|
|
||||||
ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()
|
|
||||||
} ?: {
|
|
||||||
transaction {
|
|
||||||
ExtensionTable.insert {
|
|
||||||
it[this.apkName] = apkName
|
|
||||||
}
|
|
||||||
ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()!!
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
val extensionId = extensionRecord[ExtensionTable.id]
|
|
||||||
|
|
||||||
// check if we don't have the extension already installed
|
// check if we don't have the extension already installed
|
||||||
if (!extensionRecord[ExtensionTable.isInstalled]) {
|
// if it's installed and we want to update, it first has to be uninstalled
|
||||||
|
val isInstalled = transaction {
|
||||||
|
ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()
|
||||||
|
}?.get(ExtensionTable.isInstalled) ?: false
|
||||||
|
|
||||||
|
if (!isInstalled) {
|
||||||
val fileNameWithoutType = apkName.substringBefore(".apk")
|
val fileNameWithoutType = apkName.substringBefore(".apk")
|
||||||
|
|
||||||
val dirPathWithoutType = "${ApplicationDirs.extensionsRoot}/$fileNameWithoutType"
|
val dirPathWithoutType = "${ApplicationDirs.extensionsRoot}/$fileNameWithoutType"
|
||||||
val jarFilePath = "$dirPathWithoutType.jar"
|
val jarFilePath = "$dirPathWithoutType.jar"
|
||||||
val dexFilePath = "$dirPathWithoutType.dex"
|
val dexFilePath = "$dirPathWithoutType.dex"
|
||||||
|
|
||||||
val className: String = APKExtractor.extractDexAndReadClassname(apkFilePath, dexFilePath)
|
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")
|
logger.debug("Main class for extension is $className")
|
||||||
|
|
||||||
dex2jar(dexFilePath, jarFilePath, fileNameWithoutType)
|
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
||||||
|
|
||||||
// clean up
|
// clean up
|
||||||
// File(apkFilePath).delete()
|
// File(apkFilePath).delete()
|
||||||
File(dexFilePath).delete()
|
File(dexFilePath).delete()
|
||||||
|
|
||||||
// update sources of the extension
|
// collect sources from the extension
|
||||||
val instance = loadExtensionInstance(jarFilePath, className)
|
val sources: List<CatalogueSource> = when (val instance = loadExtensionSources(jarFilePath, className)) {
|
||||||
|
is Source -> listOf(instance)
|
||||||
|
is SourceFactory -> instance.createSources()
|
||||||
|
|
||||||
when (instance) {
|
|
||||||
is HttpSource -> { // single source
|
|
||||||
transaction {
|
|
||||||
if (SourceTable.select { SourceTable.id eq instance.id }.count() == 0L) {
|
|
||||||
SourceTable.insert {
|
|
||||||
it[id] = instance.id
|
|
||||||
it[name] = instance.name
|
|
||||||
it[lang] = instance.lang
|
|
||||||
it[extension] = extensionId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug("Installed source ${instance.name} with id ${instance.id}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is SourceFactory -> { // theme source or multi lang
|
|
||||||
transaction {
|
|
||||||
instance.createSources().forEach { source ->
|
|
||||||
val httpSource = source as HttpSource
|
|
||||||
if (SourceTable.select { SourceTable.id eq httpSource.id }.count() == 0L) {
|
|
||||||
SourceTable.insert {
|
|
||||||
it[id] = httpSource.id
|
|
||||||
it[name] = httpSource.name
|
|
||||||
it[lang] = httpSource.lang
|
|
||||||
it[extension] = extensionId
|
|
||||||
it[partOfFactorySource] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug("Installed source ${httpSource.name} with id:${httpSource.id}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
throw RuntimeException("Extension content is unexpected")
|
throw RuntimeException("Unknown source class type! ${instance.javaClass}")
|
||||||
}
|
}
|
||||||
|
}.map { it as CatalogueSource }
|
||||||
|
|
||||||
|
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
|
// update extension info
|
||||||
transaction {
|
transaction {
|
||||||
ExtensionTable.update({ ExtensionTable.apkName eq apkName }) {
|
if (ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
|
||||||
it[isInstalled] = true
|
ExtensionTable.insert {
|
||||||
it[classFQName] = className
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||||
|
it[this.isInstalled] = true
|
||||||
|
it[this.classFQName] = className
|
||||||
|
}
|
||||||
|
|
||||||
|
val extensionId = ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.firstOrNull()!![ExtensionTable.id].value
|
||||||
|
|
||||||
|
sources.forEach { httpSource ->
|
||||||
|
SourceTable.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
|
return 201 // we installed successfully
|
||||||
|
@ -1,250 +0,0 @@
|
|||||||
package ir.armor.tachidesk.impl.util
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 mu.KotlinLogging
|
|
||||||
import org.w3c.dom.Document
|
|
||||||
import org.xml.sax.InputSource
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.StringReader
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.nio.file.Paths
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
import javax.xml.parsers.DocumentBuilderFactory
|
|
||||||
|
|
||||||
object APKExtractor {
|
|
||||||
private val logger = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
// decompressXML -- Parse the 'compressed' binary form of Android XML docs
|
|
||||||
// such as for AndroidManifest.xml in .apk files
|
|
||||||
private const val endDocTag = 0x00100101
|
|
||||||
private const val startTag = 0x00100102
|
|
||||||
private const val endTag = 0x00100103
|
|
||||||
|
|
||||||
private fun decompressXML(xml: ByteArray): String {
|
|
||||||
val finalXML = StringBuilder()
|
|
||||||
|
|
||||||
// Compressed XML file/bytes starts with 24x bytes of data,
|
|
||||||
// 9 32 bit words in little endian order (LSB first):
|
|
||||||
// 0th word is 03 00 08 00
|
|
||||||
// 3rd word SEEMS TO BE: Offset at then of StringTable
|
|
||||||
// 4th word is: Number of strings in string table
|
|
||||||
// WARNING: Sometime I indiscriminently display or refer to word in
|
|
||||||
// little endian storage format, or in integer format (ie MSB first).
|
|
||||||
val numbStrings = LEW(xml, 4 * 4)
|
|
||||||
|
|
||||||
// StringIndexTable starts at offset 24x, an array of 32 bit LE offsets
|
|
||||||
// of the length/string data in the StringTable.
|
|
||||||
val sitOff = 0x24 // Offset of start of StringIndexTable
|
|
||||||
|
|
||||||
// StringTable, each string is represented with a 16 bit little endian
|
|
||||||
// character count, followed by that number of 16 bit (LE) (Unicode)
|
|
||||||
// chars.
|
|
||||||
val stOff = sitOff + numbStrings * 4 // StringTable follows
|
|
||||||
// StrIndexTable
|
|
||||||
|
|
||||||
// XMLTags, The XML tag tree starts after some unknown content after the
|
|
||||||
// StringTable. There is some unknown data after the StringTable, scan
|
|
||||||
// forward from this point to the flag for the start of an XML start
|
|
||||||
// tag.
|
|
||||||
var xmlTagOff = LEW(xml, 3 * 4) // Start from the offset in the 3rd
|
|
||||||
// word.
|
|
||||||
// Scan forward until we find the bytes: 0x02011000(x00100102 in normal
|
|
||||||
// int)
|
|
||||||
var ii = xmlTagOff
|
|
||||||
while (ii < xml.size - 4) {
|
|
||||||
if (LEW(xml, ii) == startTag) {
|
|
||||||
xmlTagOff = ii
|
|
||||||
break
|
|
||||||
}
|
|
||||||
ii += 4
|
|
||||||
}
|
|
||||||
|
|
||||||
// XML tags and attributes:
|
|
||||||
// Every XML start and end tag consists of 6 32 bit words:
|
|
||||||
// 0th word: 02011000 for startTag and 03011000 for endTag
|
|
||||||
// 1st word: a flag?, like 38000000
|
|
||||||
// 2nd word: Line of where this tag appeared in the original source file
|
|
||||||
// 3rd word: FFFFFFFF ??
|
|
||||||
// 4th word: StringIndex of NameSpace name, or FFFFFFFF for default NS
|
|
||||||
// 5th word: StringIndex of Element Name
|
|
||||||
// (Note: 01011000 in 0th word means end of XML document, endDocTag)
|
|
||||||
|
|
||||||
// Start tags (not end tags) contain 3 more words:
|
|
||||||
// 6th word: 14001400 meaning??
|
|
||||||
// 7th word: Number of Attributes that follow this tag(follow word 8th)
|
|
||||||
// 8th word: 00000000 meaning??
|
|
||||||
|
|
||||||
// Attributes consist of 5 words:
|
|
||||||
// 0th word: StringIndex of Attribute Name's Namespace, or FFFFFFFF
|
|
||||||
// 1st word: StringIndex of Attribute Name
|
|
||||||
// 2nd word: StringIndex of Attribute Value, or FFFFFFF if ResourceId
|
|
||||||
// used
|
|
||||||
// 3rd word: Flags?
|
|
||||||
// 4th word: str ind of attr value again, or ResourceId of value
|
|
||||||
|
|
||||||
// TMP, dump string table to tr for debugging
|
|
||||||
// tr.addSelect("strings", null);
|
|
||||||
// for (int ii=0; ii<numbStrings; ii++) {
|
|
||||||
// // Length of string starts at StringTable plus offset in StrIndTable
|
|
||||||
// String str = compXmlString(xml, sitOff, stOff, ii);
|
|
||||||
// tr.add(String.valueOf(ii), str);
|
|
||||||
// }
|
|
||||||
// tr.parent();
|
|
||||||
|
|
||||||
// Step through the XML tree element tags and attributes
|
|
||||||
var off = xmlTagOff
|
|
||||||
var indent = 0
|
|
||||||
var startTagLineNo = -2
|
|
||||||
while (off < xml.size) {
|
|
||||||
val tag0 = LEW(xml, off)
|
|
||||||
// int tag1 = LEW(xml, off+1*4);
|
|
||||||
val lineNo = LEW(xml, off + 2 * 4)
|
|
||||||
// int tag3 = LEW(xml, off+3*4);
|
|
||||||
val nameNsSi = LEW(xml, off + 4 * 4)
|
|
||||||
val nameSi = LEW(xml, off + 5 * 4)
|
|
||||||
if (tag0 == startTag) { // XML START TAG
|
|
||||||
val tag6 = LEW(xml, off + 6 * 4) // Expected to be 14001400
|
|
||||||
val numbAttrs = LEW(xml, off + 7 * 4) // Number of Attributes
|
|
||||||
// to follow
|
|
||||||
// int tag8 = LEW(xml, off+8*4); // Expected to be 00000000
|
|
||||||
off += 9 * 4 // Skip over 6+3 words of startTag data
|
|
||||||
val name = compXmlString(xml, sitOff, stOff, nameSi)
|
|
||||||
// tr.addSelect(name, null);
|
|
||||||
startTagLineNo = lineNo
|
|
||||||
|
|
||||||
// Look for the Attributes
|
|
||||||
val sb = StringBuffer()
|
|
||||||
for (ii in 0 until numbAttrs) {
|
|
||||||
val attrNameNsSi = LEW(xml, off) // AttrName Namespace Str
|
|
||||||
// Ind, or FFFFFFFF
|
|
||||||
val attrNameSi = LEW(xml, off + 1 * 4) // AttrName String
|
|
||||||
// Index
|
|
||||||
val attrValueSi = LEW(xml, off + 2 * 4) // AttrValue Str
|
|
||||||
// Ind, or
|
|
||||||
// FFFFFFFF
|
|
||||||
val attrFlags = LEW(xml, off + 3 * 4)
|
|
||||||
val attrResId = LEW(xml, off + 4 * 4) // AttrValue
|
|
||||||
// ResourceId or dup
|
|
||||||
// AttrValue StrInd
|
|
||||||
off += 5 * 4 // Skip over the 5 words of an attribute
|
|
||||||
val attrName = compXmlString(
|
|
||||||
xml, sitOff, stOff,
|
|
||||||
attrNameSi
|
|
||||||
)
|
|
||||||
val attrValue = if (attrValueSi != -1) compXmlString(xml, sitOff, stOff, attrValueSi)
|
|
||||||
else "resourceID 0x ${Integer.toHexString(attrResId)}"
|
|
||||||
sb.append(" $attrName=\"$attrValue\"")
|
|
||||||
// tr.add(attrName, attrValue);
|
|
||||||
}
|
|
||||||
finalXML.append("<$name$sb>")
|
|
||||||
prtIndent(indent, "<$name$sb>")
|
|
||||||
indent++
|
|
||||||
} else if (tag0 == endTag) { // XML END TAG
|
|
||||||
indent--
|
|
||||||
off += 6 * 4 // Skip over 6 words of endTag data
|
|
||||||
val name = compXmlString(xml, sitOff, stOff, nameSi)
|
|
||||||
finalXML.append("</$name>")
|
|
||||||
prtIndent(
|
|
||||||
indent,
|
|
||||||
"</" + name + "> (line " + startTagLineNo +
|
|
||||||
"-" + lineNo + ")"
|
|
||||||
)
|
|
||||||
// tr.parent(); // Step back up the NobTree
|
|
||||||
} else if (tag0 == endDocTag) { // END OF XML DOC TAG
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
" Unrecognized tag code '${Integer.toHexString(tag0)}'' at offset $off"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} // end of while loop scanning tags and attributes of XML tree
|
|
||||||
logger.debug(" end at offset $off")
|
|
||||||
return finalXML.toString()
|
|
||||||
} // end of decompressXML
|
|
||||||
|
|
||||||
private fun compXmlString(xml: ByteArray, sitOff: Int, stOff: Int, strInd: Int): String? {
|
|
||||||
if (strInd < 0) return null
|
|
||||||
val strOff = stOff + LEW(xml, sitOff + strInd * 4)
|
|
||||||
return compXmlStringAt(xml, strOff)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var spaces = " "
|
|
||||||
private fun prtIndent(indent: Int, str: String) {
|
|
||||||
logger.debug(spaces.substring(0, Math.min(indent * 2, spaces.length)) + str)
|
|
||||||
}
|
|
||||||
|
|
||||||
// compXmlStringAt -- Return the string stored in StringTable format at
|
|
||||||
// offset strOff. This offset points to the 16 bit string length, which
|
|
||||||
// is followed by that number of 16 bit (Unicode) chars.
|
|
||||||
private fun compXmlStringAt(arr: ByteArray, strOff: Int): String {
|
|
||||||
val strLen: Int = arr[strOff + 1].toInt() shl 8 and 0xff00 or arr[strOff].toInt() and 0xff
|
|
||||||
val chars = ByteArray(strLen)
|
|
||||||
for (ii in 0 until strLen) {
|
|
||||||
chars[ii] = arr[strOff + 2 + ii * 2]
|
|
||||||
}
|
|
||||||
return String(chars) // Hack, just use 8 byte chars
|
|
||||||
} // end of compXmlStringAt
|
|
||||||
|
|
||||||
// LEW -- Return value of a Little Endian 32 bit word from the byte array
|
|
||||||
// at offset off.
|
|
||||||
private fun LEW(arr: ByteArray, off: Int): Int {
|
|
||||||
|
|
||||||
return (arr[off + 3].toInt() shl 24) and -0x1000000 or
|
|
||||||
(arr[off + 2].toInt() shl 16 and 0xff0000) or
|
|
||||||
(arr[off + 1].toInt() shl 8 and 0xff00) or
|
|
||||||
(arr[off].toInt() and 0xFF)
|
|
||||||
} // end of LEW
|
|
||||||
|
|
||||||
@Throws(Exception::class)
|
|
||||||
private fun loadXMLFromString(xml: String?): Document {
|
|
||||||
return DocumentBuilderFactory.newInstance()
|
|
||||||
.newDocumentBuilder()
|
|
||||||
.parse(InputSource(StringReader(xml)))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun extractDexAndReadClassname(filePath: String, dexPath: String): String {
|
|
||||||
ZipFile(filePath).use { zip ->
|
|
||||||
val androidManifest = zip.getEntry("AndroidManifest.xml")
|
|
||||||
val classesDex = zip.getEntry("classes.dex")
|
|
||||||
|
|
||||||
// write dex file
|
|
||||||
zip.getInputStream(classesDex).use { dexInputStream ->
|
|
||||||
Files.newOutputStream(Paths.get(dexPath)).use { fileOutputStream ->
|
|
||||||
dexInputStream.copyTo(fileOutputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// read xml file
|
|
||||||
val xml = zip.getInputStream(androidManifest).use { inpStream ->
|
|
||||||
// 1024000 = 100 kb
|
|
||||||
ByteArray(1024000).let {
|
|
||||||
inpStream.read(it)
|
|
||||||
decompressXML(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val xmlDoc = loadXMLFromString(xml)
|
|
||||||
val pkg = xmlDoc.documentElement.getAttribute("package")
|
|
||||||
val nodes = xmlDoc.getElementsByTagName("meta-data")
|
|
||||||
for (i in 0 until nodes.length) {
|
|
||||||
val attributes = nodes.item(i).attributes
|
|
||||||
logger.debug(attributes.getNamedItem("name").nodeValue)
|
|
||||||
if (attributes.getNamedItem("name").nodeValue == "tachiyomi.extension.class") {
|
|
||||||
return pkg + attributes.getNamedItem("value").nodeValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -9,7 +9,7 @@ package ir.armor.tachidesk.impl.util
|
|||||||
|
|
||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import ir.armor.tachidesk.impl.Extension
|
import ir.armor.tachidesk.impl.util.PackageTools.loadExtensionSources
|
||||||
import ir.armor.tachidesk.model.database.ExtensionTable
|
import ir.armor.tachidesk.model.database.ExtensionTable
|
||||||
import ir.armor.tachidesk.model.database.SourceTable
|
import ir.armor.tachidesk.model.database.SourceTable
|
||||||
import ir.armor.tachidesk.server.ApplicationDirs
|
import ir.armor.tachidesk.server.ApplicationDirs
|
||||||
@ -40,7 +40,7 @@ object GetHttpSource {
|
|||||||
val jarName = apkName.substringBefore(".apk") + ".jar"
|
val jarName = apkName.substringBefore(".apk") + ".jar"
|
||||||
val jarPath = "${ApplicationDirs.extensionsRoot}/$jarName"
|
val jarPath = "${ApplicationDirs.extensionsRoot}/$jarName"
|
||||||
|
|
||||||
val extensionInstance = Extension.loadExtensionInstance(jarPath, className)
|
val extensionInstance = loadExtensionSources(jarPath, className)
|
||||||
|
|
||||||
if (sourceRecord[SourceTable.partOfFactorySource]) {
|
if (sourceRecord[SourceTable.partOfFactorySource]) {
|
||||||
(extensionInstance as SourceFactory).createSources().forEach {
|
(extensionInstance as SourceFactory).createSources().forEach {
|
||||||
|
@ -0,0 +1,137 @@
|
|||||||
|
package ir.armor.tachidesk.impl.util
|
||||||
|
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.Signature
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.googlecode.d2j.dex.Dex2jar
|
||||||
|
import com.googlecode.d2j.reader.MultiDexFileReader
|
||||||
|
import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler
|
||||||
|
import eu.kanade.tachiyomi.util.lang.Hash
|
||||||
|
import ir.armor.tachidesk.server.ApplicationDirs
|
||||||
|
import mu.KotlinLogging
|
||||||
|
import net.dongliu.apk.parser.ApkFile
|
||||||
|
import net.dongliu.apk.parser.ApkParsers
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
|
||||||
|
import xyz.nulldev.androidcompat.pm.toPackageInfo
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLClassLoader
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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/. */
|
||||||
|
|
||||||
|
object PackageTools {
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
const val EXTENSION_FEATURE = "tachiyomi.extension"
|
||||||
|
const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||||
|
const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
|
||||||
|
const val METADATA_NSFW = "tachiyomi.extension.nsfw"
|
||||||
|
const val LIB_VERSION_MIN = 1.2
|
||||||
|
const val LIB_VERSION_MAX = 1.2
|
||||||
|
|
||||||
|
// inorichi's key
|
||||||
|
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
|
||||||
|
var trustedSignatures = mutableSetOf<String>() + officialSignature
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert dex to jar, a wrapper for the dex2jar library
|
||||||
|
*/
|
||||||
|
fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) {
|
||||||
|
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
|
||||||
|
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
|
||||||
|
|
||||||
|
val jarFilePath = File(jarFile).toPath()
|
||||||
|
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
|
||||||
|
val handler = BaksmaliBaseDexExceptionHandler()
|
||||||
|
Dex2jar
|
||||||
|
.from(reader)
|
||||||
|
.withExceptionHandler(handler)
|
||||||
|
.reUseReg(false)
|
||||||
|
.topoLogicalSort()
|
||||||
|
.skipDebug(true)
|
||||||
|
.optimizeSynchronized(false)
|
||||||
|
.printIR(false)
|
||||||
|
.noCode(false)
|
||||||
|
.skipExceptions(false)
|
||||||
|
.to(jarFilePath)
|
||||||
|
if (handler.hasException()) {
|
||||||
|
val errorFile: Path = File(ApplicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt")
|
||||||
|
logger.error(
|
||||||
|
"Detail Error Information in File $errorFile\n" +
|
||||||
|
"Please report this file to one of following link if possible (any one).\n" +
|
||||||
|
" https://sourceforge.net/p/dex2jar/tickets/\n" +
|
||||||
|
" https://bitbucket.org/pxb1988/dex2jar/issues\n" +
|
||||||
|
" https://github.com/pxb1988/dex2jar/issues\n" +
|
||||||
|
" dex2jar@googlegroups.com"
|
||||||
|
)
|
||||||
|
handler.dump(errorFile, emptyArray<String>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
|
||||||
|
fun getPackageInfo(apkFilePath: String): PackageInfo {
|
||||||
|
val apk = File(apkFilePath)
|
||||||
|
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
|
||||||
|
val parsed = ApkFile(apk)
|
||||||
|
val dbFactory = DocumentBuilderFactory.newInstance()
|
||||||
|
val dBuilder = dbFactory.newDocumentBuilder()
|
||||||
|
val doc = parsed.manifestXml.byteInputStream().use {
|
||||||
|
dBuilder.parse(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(parsed.manifestXml)
|
||||||
|
|
||||||
|
applicationInfo.metaData = Bundle().apply {
|
||||||
|
val appTag = doc.getElementsByTagName("application").item(0)
|
||||||
|
|
||||||
|
appTag?.childNodes?.toList()?.filter {
|
||||||
|
it.nodeType == Node.ELEMENT_NODE
|
||||||
|
}?.map {
|
||||||
|
it as Element
|
||||||
|
}?.filter {
|
||||||
|
it.tagName == "meta-data"
|
||||||
|
}?.map {
|
||||||
|
putString(
|
||||||
|
it.attributes.getNamedItem("android:name").nodeValue,
|
||||||
|
it.attributes.getNamedItem("android:value").nodeValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signatures = (
|
||||||
|
parsed.apkSingers.flatMap { it.certificateMetas }
|
||||||
|
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/
|
||||||
|
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
|
||||||
|
.map { Signature(it.data) }.toTypedArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSignatureHash(pkgInfo: PackageInfo): String? {
|
||||||
|
val signatures = pkgInfo.signatures
|
||||||
|
return if (signatures != null && signatures.isNotEmpty()) {
|
||||||
|
Hash.sha256(signatures.first().toByteArray())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loads the extension main class called $className from the jar located at $jarPath
|
||||||
|
* It may return an instance of HttpSource or SourceFactory depending on the extension.
|
||||||
|
*/
|
||||||
|
fun loadExtensionSources(jarPath: String, className: String): Any {
|
||||||
|
val classLoader = URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
|
||||||
|
val classToLoad = Class.forName(className, false, classLoader)
|
||||||
|
return classToLoad.getDeclaredConstructor().newInstance()
|
||||||
|
}
|
||||||
|
}
|
@ -16,15 +16,16 @@ object ExtensionTable : IntIdTable() {
|
|||||||
val iconUrl = varchar("icon_url", 2048)
|
val iconUrl = varchar("icon_url", 2048)
|
||||||
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
|
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
|
||||||
|
|
||||||
val name = varchar("name", 128).nullable().default(null)
|
val name = varchar("name", 128)
|
||||||
val pkgName = varchar("pkg_name", 128).nullable().default(null)
|
val pkgName = varchar("pkg_name", 128)
|
||||||
val versionName = varchar("version_name", 16).nullable().default(null)
|
val versionName = varchar("version_name", 16)
|
||||||
val versionCode = integer("version_code").default(0)
|
val versionCode = integer("version_code")
|
||||||
val lang = varchar("lang", 10).nullable().default(null)
|
val lang = varchar("lang", 3)
|
||||||
val isNsfw = bool("is_nsfw").nullable().default(null)
|
val isNsfw = bool("is_nsfw")
|
||||||
|
|
||||||
val isInstalled = bool("is_installed").default(false)
|
val isInstalled = bool("is_installed").default(false)
|
||||||
val hasUpdate = bool("has_update").default(false)
|
val hasUpdate = bool("has_update").default(false)
|
||||||
val isObsolete = bool("is_obsolete").default(false)
|
val isObsolete = bool("is_obsolete").default(false)
|
||||||
|
|
||||||
val classFQName = varchar("class_name", 1024).default("") // fully qualified name
|
val classFQName = varchar("class_name", 1024).default("") // fully qualified name
|
||||||
}
|
}
|
||||||
|
@ -10,12 +10,14 @@ package ir.armor.tachidesk.model.dataclass
|
|||||||
data class ExtensionDataClass(
|
data class ExtensionDataClass(
|
||||||
val apkName: String,
|
val apkName: String,
|
||||||
val iconUrl: String,
|
val iconUrl: String,
|
||||||
val name: String?,
|
|
||||||
val pkgName: String?,
|
val name: String,
|
||||||
val versionName: String?,
|
val pkgName: String,
|
||||||
val versionCode: Int?,
|
val versionName: String,
|
||||||
val lang: String?,
|
val versionCode: Int,
|
||||||
val isNsfw: Boolean?,
|
val lang: String,
|
||||||
|
val isNsfw: Boolean,
|
||||||
|
|
||||||
val installed: Boolean,
|
val installed: Boolean,
|
||||||
val hasUpdate: Boolean,
|
val hasUpdate: Boolean,
|
||||||
val obsolete: Boolean,
|
val obsolete: Boolean,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user