|
@@ -1,7 +1,7 @@
|
|
|
package eu.kanade.tachiyomi.extension.util
|
|
|
|
|
|
-import android.annotation.SuppressLint
|
|
|
import android.content.Context
|
|
|
+import android.content.pm.ApplicationInfo
|
|
|
import android.content.pm.PackageInfo
|
|
|
import android.content.pm.PackageManager
|
|
|
import android.os.Build
|
|
@@ -14,17 +14,28 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
|
|
import eu.kanade.tachiyomi.source.Source
|
|
|
import eu.kanade.tachiyomi.source.SourceFactory
|
|
|
import eu.kanade.tachiyomi.util.lang.Hash
|
|
|
-import eu.kanade.tachiyomi.util.system.getApplicationIcon
|
|
|
import kotlinx.coroutines.async
|
|
|
+import kotlinx.coroutines.awaitAll
|
|
|
import kotlinx.coroutines.runBlocking
|
|
|
import logcat.LogPriority
|
|
|
import tachiyomi.core.util.system.logcat
|
|
|
import uy.kohesive.injekt.injectLazy
|
|
|
+import java.io.File
|
|
|
|
|
|
/**
|
|
|
- * Class that handles the loading of the extensions installed in the system.
|
|
|
+ * Class that handles the loading of the extensions. Supports two kinds of extensions:
|
|
|
+ *
|
|
|
+ * 1. Shared extension: This extension is installed to the system with package
|
|
|
+ * installer, so other variants of Tachiyomi and its forks can also use this extension.
|
|
|
+ *
|
|
|
+ * 2. Private extension: This extension is put inside private data directory of the
|
|
|
+ * running app, so this extension can only be used by the running app and not shared
|
|
|
+ * with other apps.
|
|
|
+ *
|
|
|
+ * When both kinds of extensions are installed with a same package name, shared
|
|
|
+ * extension will be used unless the version codes are different. In that case the
|
|
|
+ * one with higher version code will be used.
|
|
|
*/
|
|
|
-@SuppressLint("PackageManagerGetSignatures")
|
|
|
internal object ExtensionLoader {
|
|
|
|
|
|
private val preferences: SourcePreferences by injectLazy()
|
|
@@ -41,12 +52,11 @@ internal object ExtensionLoader {
|
|
|
const val LIB_VERSION_MIN = 1.4
|
|
|
const val LIB_VERSION_MAX = 1.5
|
|
|
|
|
|
- private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
|
- PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES
|
|
|
- } else {
|
|
|
- @Suppress("DEPRECATION")
|
|
|
- PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
|
|
- }
|
|
|
+ @Suppress("DEPRECATION")
|
|
|
+ private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
|
|
|
+ PackageManager.GET_META_DATA or
|
|
|
+ PackageManager.GET_SIGNATURES or
|
|
|
+ (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
|
|
|
|
|
|
// inorichi's key
|
|
|
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
|
|
@@ -56,8 +66,57 @@ internal object ExtensionLoader {
|
|
|
*/
|
|
|
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get()
|
|
|
|
|
|
+ private const val PRIVATE_EXTENSION_EXTENSION = "ext"
|
|
|
+
|
|
|
+ private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "exts")
|
|
|
+
|
|
|
+ fun installPrivateExtensionFile(context: Context, file: File): Boolean {
|
|
|
+ val extension = context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS)
|
|
|
+ ?.takeIf { isPackageAnExtension(it) } ?: return false
|
|
|
+ val currentExtension = getExtensionPackageInfoFromPkgName(context, extension.packageName)
|
|
|
+
|
|
|
+ if (currentExtension != null) {
|
|
|
+ if (PackageInfoCompat.getLongVersionCode(extension) <
|
|
|
+ PackageInfoCompat.getLongVersionCode(currentExtension)
|
|
|
+ ) {
|
|
|
+ logcat(LogPriority.ERROR) { "Installed extension version is higher. Downgrading is not allowed." }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ val extensionSignatures = getSignatures(extension)
|
|
|
+ if (extensionSignatures.isNullOrEmpty()) {
|
|
|
+ logcat(LogPriority.ERROR) { "Extension to be installed is not signed." }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) {
|
|
|
+ logcat(LogPriority.ERROR) { "Installed extension signature is not matched." }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION")
|
|
|
+ return try {
|
|
|
+ file.copyTo(target, overwrite = true)
|
|
|
+ if (currentExtension != null) {
|
|
|
+ ExtensionInstallReceiver.notifyReplaced(context, extension.packageName)
|
|
|
+ } else {
|
|
|
+ ExtensionInstallReceiver.notifyAdded(context, extension.packageName)
|
|
|
+ }
|
|
|
+ true
|
|
|
+ } catch (e: Exception) {
|
|
|
+ logcat(LogPriority.ERROR, e) { "Failed to copy extension file." }
|
|
|
+ target.delete()
|
|
|
+ false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fun uninstallPrivateExtension(context: Context, pkgName: String) {
|
|
|
+ File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION").delete()
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
- * Return a list of all the installed extensions initialized concurrently.
|
|
|
+ * Return a list of all the available extensions initialized concurrently.
|
|
|
*
|
|
|
* @param context The application context.
|
|
|
*/
|
|
@@ -70,16 +129,43 @@ internal object ExtensionLoader {
|
|
|
pkgManager.getInstalledPackages(PACKAGE_FLAGS)
|
|
|
}
|
|
|
|
|
|
- val extPkgs = installedPkgs.filter { isPackageAnExtension(it) }
|
|
|
+ val sharedExtPkgs = installedPkgs
|
|
|
+ .asSequence()
|
|
|
+ .filter { isPackageAnExtension(it) }
|
|
|
+ .map { ExtensionInfo(packageInfo = it, isShared = true) }
|
|
|
+
|
|
|
+ val privateExtPkgs = getPrivateExtensionDir(context)
|
|
|
+ .listFiles()
|
|
|
+ ?.asSequence()
|
|
|
+ ?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION }
|
|
|
+ ?.mapNotNull {
|
|
|
+ val path = it.absolutePath
|
|
|
+ pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
|
|
|
+ ?.apply { applicationInfo.fixBasePaths(path) }
|
|
|
+ }
|
|
|
+ ?.filter { isPackageAnExtension(it) }
|
|
|
+ ?.map { ExtensionInfo(packageInfo = it, isShared = false) }
|
|
|
+ ?: emptySequence()
|
|
|
+
|
|
|
+ val extPkgs = (sharedExtPkgs + privateExtPkgs)
|
|
|
+ // Remove duplicates. Shared takes priority than private by default
|
|
|
+ .distinctBy { it.packageInfo.packageName }
|
|
|
+ // Compare version number
|
|
|
+ .mapNotNull { sharedPkg ->
|
|
|
+ val privatePkg = privateExtPkgs
|
|
|
+ .singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName }
|
|
|
+ selectExtensionPackage(sharedPkg, privatePkg)
|
|
|
+ }
|
|
|
+ .toList()
|
|
|
|
|
|
if (extPkgs.isEmpty()) return emptyList()
|
|
|
|
|
|
// Load each extension concurrently and wait for completion
|
|
|
return runBlocking {
|
|
|
val deferred = extPkgs.map {
|
|
|
- async { loadExtension(context, it.packageName, it) }
|
|
|
+ async { loadExtension(context, it) }
|
|
|
}
|
|
|
- deferred.map { it.await() }
|
|
|
+ deferred.awaitAll()
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -88,37 +174,61 @@ internal object ExtensionLoader {
|
|
|
* contains the required feature flag before trying to load it.
|
|
|
*/
|
|
|
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
|
|
|
- val pkgInfo = try {
|
|
|
- context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
|
|
- } catch (error: PackageManager.NameNotFoundException) {
|
|
|
- // Unlikely, but the package may have been uninstalled at this point
|
|
|
- logcat(LogPriority.ERROR, error)
|
|
|
+ val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
|
|
|
+ if (extensionPackage == null) {
|
|
|
+ logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
|
|
|
return LoadResult.Error
|
|
|
}
|
|
|
- if (!isPackageAnExtension(pkgInfo)) {
|
|
|
- logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
|
|
|
- return LoadResult.Error
|
|
|
+ return loadExtension(context, extensionPackage)
|
|
|
+ }
|
|
|
+
|
|
|
+ fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
|
|
|
+ return getExtensionInfoFromPkgName(context, pkgName)?.packageInfo
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun getExtensionInfoFromPkgName(context: Context, pkgName: String): ExtensionInfo? {
|
|
|
+ val privateExtensionFile = File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION")
|
|
|
+ val privatePkg = if (privateExtensionFile.isFile) {
|
|
|
+ context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS)
|
|
|
+ ?.takeIf { isPackageAnExtension(it) }
|
|
|
+ ?.let {
|
|
|
+ it.applicationInfo.fixBasePaths(privateExtensionFile.absolutePath)
|
|
|
+ ExtensionInfo(
|
|
|
+ packageInfo = it,
|
|
|
+ isShared = false,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ null
|
|
|
}
|
|
|
- return loadExtension(context, pkgName, pkgInfo)
|
|
|
+
|
|
|
+ val sharedPkg = try {
|
|
|
+ context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
|
|
|
+ .takeIf { isPackageAnExtension(it) }
|
|
|
+ ?.let {
|
|
|
+ ExtensionInfo(
|
|
|
+ packageInfo = it,
|
|
|
+ isShared = true,
|
|
|
+ )
|
|
|
+ }
|
|
|
+ } catch (error: PackageManager.NameNotFoundException) {
|
|
|
+ null
|
|
|
+ }
|
|
|
+
|
|
|
+ return selectExtensionPackage(sharedPkg, privatePkg)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Loads an extension given its package name.
|
|
|
+ * Loads an extension
|
|
|
*
|
|
|
* @param context The application context.
|
|
|
- * @param pkgName The package name of the extension to load.
|
|
|
- * @param pkgInfo The package info of the extension.
|
|
|
+ * @param extensionInfo The extension to load.
|
|
|
*/
|
|
|
- private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): LoadResult {
|
|
|
+ private fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
|
|
|
val pkgManager = context.packageManager
|
|
|
-
|
|
|
- val appInfo = try {
|
|
|
- pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA)
|
|
|
- } catch (error: PackageManager.NameNotFoundException) {
|
|
|
- // Unlikely, but the package may have been uninstalled at this point
|
|
|
- logcat(LogPriority.ERROR, error)
|
|
|
- return LoadResult.Error
|
|
|
- }
|
|
|
+ val pkgInfo = extensionInfo.packageInfo
|
|
|
+ val appInfo = pkgInfo.applicationInfo
|
|
|
+ val pkgName = pkgInfo.packageName
|
|
|
|
|
|
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
|
|
|
val versionName = pkgInfo.versionName
|
|
@@ -139,12 +249,19 @@ internal object ExtensionLoader {
|
|
|
return LoadResult.Error
|
|
|
}
|
|
|
|
|
|
- val signatureHash = getSignatureHash(context, pkgInfo)
|
|
|
- if (signatureHash == null) {
|
|
|
+ val signatures = getSignatures(pkgInfo)
|
|
|
+ if (signatures.isNullOrEmpty()) {
|
|
|
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
|
|
return LoadResult.Error
|
|
|
- } else if (signatureHash !in trustedSignatures) {
|
|
|
- val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash)
|
|
|
+ } else if (!hasTrustedSignature(signatures)) {
|
|
|
+ val extension = Extension.Untrusted(
|
|
|
+ extName,
|
|
|
+ pkgName,
|
|
|
+ versionName,
|
|
|
+ versionCode,
|
|
|
+ libVersion,
|
|
|
+ signatures.last(),
|
|
|
+ )
|
|
|
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
|
|
|
return LoadResult.Untrusted(extension)
|
|
|
}
|
|
@@ -204,12 +321,35 @@ internal object ExtensionLoader {
|
|
|
hasChangelog = hasChangelog,
|
|
|
sources = sources,
|
|
|
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
|
|
- isUnofficial = signatureHash != officialSignature,
|
|
|
- icon = context.getApplicationIcon(pkgName),
|
|
|
+ isUnofficial = !isOfficiallySigned(signatures),
|
|
|
+ icon = appInfo.loadIcon(pkgManager),
|
|
|
+ isShared = extensionInfo.isShared,
|
|
|
)
|
|
|
return LoadResult.Success(extension)
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Choose which extension package to use based on version code
|
|
|
+ *
|
|
|
+ * @param shared extension installed to system
|
|
|
+ * @param private extension installed to data directory
|
|
|
+ */
|
|
|
+ private fun selectExtensionPackage(shared: ExtensionInfo?, private: ExtensionInfo?): ExtensionInfo? {
|
|
|
+ when {
|
|
|
+ private == null && shared != null -> return shared
|
|
|
+ shared == null && private != null -> return private
|
|
|
+ shared == null && private == null -> return null
|
|
|
+ }
|
|
|
+
|
|
|
+ return if (PackageInfoCompat.getLongVersionCode(shared!!.packageInfo) >=
|
|
|
+ PackageInfoCompat.getLongVersionCode(private!!.packageInfo)
|
|
|
+ ) {
|
|
|
+ shared
|
|
|
+ } else {
|
|
|
+ private
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Returns true if the given package is an extension.
|
|
|
*
|
|
@@ -220,12 +360,50 @@ internal object ExtensionLoader {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Returns the signature hash of the package or null if it's not signed.
|
|
|
+ * Returns the signatures of the package or null if it's not signed.
|
|
|
*
|
|
|
* @param pkgInfo The package info of the application.
|
|
|
+ * @return List SHA256 digest of the signatures
|
|
|
*/
|
|
|
- private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? {
|
|
|
- val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName)
|
|
|
- return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) }
|
|
|
+ private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
|
|
|
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
|
+ val signingInfo = pkgInfo.signingInfo
|
|
|
+ if (signingInfo.hasMultipleSigners()) {
|
|
|
+ signingInfo.apkContentsSigners
|
|
|
+ } else {
|
|
|
+ signingInfo.signingCertificateHistory
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ @Suppress("DEPRECATION")
|
|
|
+ pkgInfo.signatures
|
|
|
+ }
|
|
|
+ ?.map { Hash.sha256(it.toByteArray()) }
|
|
|
+ ?.toList()
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun hasTrustedSignature(signatures: List<String>): Boolean {
|
|
|
+ return trustedSignatures.any { signatures.contains(it) }
|
|
|
+ }
|
|
|
+
|
|
|
+ private fun isOfficiallySigned(signatures: List<String>): Boolean {
|
|
|
+ return signatures.all { it == officialSignature }
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * On Android 13+ the ApplicationInfo generated by getPackageArchiveInfo doesn't
|
|
|
+ * have sourceDir which breaks assets loading (used for getting icon here).
|
|
|
+ */
|
|
|
+ private fun ApplicationInfo.fixBasePaths(apkPath: String) {
|
|
|
+ if (sourceDir == null) {
|
|
|
+ sourceDir = apkPath
|
|
|
+ }
|
|
|
+ if (publicSourceDir == null) {
|
|
|
+ publicSourceDir = apkPath
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private data class ExtensionInfo(
|
|
|
+ val packageInfo: PackageInfo,
|
|
|
+ val isShared: Boolean,
|
|
|
+ )
|
|
|
}
|