Add private extension install method (#9710)

* Add private extension install method

Private extensions are put inside private data directory of the running app, so
this kind of extensions can only be used by the running app and not shared with
other apps.

One limitation of private extension is the lack of deeplink handlers (if there's
any) since the extension APK is not installed to the system.

When both kinds of extensions are installed with a same package name, shared
extension (the one installed to the system) will be used unless the version
codes are different. In that case the one with higher version code will be used.

* update
This commit is contained in:
Ivan Iskandar 2023-08-05 23:15:52 +07:00 committed by GitHub
parent 7146913c71
commit 627f07408e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 323 additions and 70 deletions

View file

@ -24,5 +24,6 @@ class BasePreferences(
LEGACY(R.string.ext_installer_legacy), LEGACY(R.string.ext_installer_legacy),
PACKAGEINSTALLER(R.string.ext_installer_packageinstaller), PACKAGEINSTALLER(R.string.ext_installer_packageinstaller),
SHIZUKU(R.string.ext_installer_shizuku), SHIZUKU(R.string.ext_installer_shizuku),
PRIVATE(R.string.ext_installer_private),
} }
} }

View file

@ -10,12 +10,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material.icons.outlined.HelpOutline
@ -176,7 +174,8 @@ private fun ExtensionDetails(
data = Uri.fromParts("package", extension.pkgName, null) data = Uri.fromParts("package", extension.pkgName, null)
context.startActivity(this) context.startActivity(this)
} }
}, Unit
}.takeIf { extension.isShared },
onClickAgeRating = { onClickAgeRating = {
showNsfwWarning = true showNsfwWarning = true
}, },
@ -209,7 +208,7 @@ private fun DetailsHeader(
extension: Extension, extension: Extension,
onClickAgeRating: () -> Unit, onClickAgeRating: () -> Unit,
onClickUninstall: () -> Unit, onClickUninstall: () -> Unit,
onClickAppInfo: () -> Unit, onClickAppInfo: (() -> Unit)?,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -293,6 +292,7 @@ private fun DetailsHeader(
top = MaterialTheme.padding.small, top = MaterialTheme.padding.small,
bottom = MaterialTheme.padding.medium, bottom = MaterialTheme.padding.medium,
), ),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
OutlinedButton( OutlinedButton(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -301,16 +301,16 @@ private fun DetailsHeader(
Text(stringResource(R.string.ext_uninstall)) Text(stringResource(R.string.ext_uninstall))
} }
Spacer(Modifier.width(16.dp)) if (onClickAppInfo != null) {
Button(
Button( modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f), onClick = onClickAppInfo,
onClick = onClickAppInfo, ) {
) { Text(
Text( text = stringResource(R.string.ext_app_info),
text = stringResource(R.string.ext_app_info), color = MaterialTheme.colorScheme.onPrimary,
color = MaterialTheme.colorScheme.onPrimary, )
) }
} }
} }

View file

@ -1,6 +1,5 @@
package eu.kanade.presentation.browse.components package eu.kanade.presentation.browse.components
import android.content.pm.PackageManager
import android.util.DisplayMetrics import android.util.DisplayMetrics
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -31,6 +30,7 @@ import eu.kanade.domain.source.model.icon
import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.presentation.util.rememberResourceBitmapPainter
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import tachiyomi.core.util.lang.withIOContext import tachiyomi.core.util.lang.withIOContext
import tachiyomi.domain.source.model.Source import tachiyomi.domain.source.model.Source
import tachiyomi.source.local.isLocal import tachiyomi.source.local.isLocal
@ -127,7 +127,7 @@ private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): St
return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) { return produceState<Result<ImageBitmap>>(initialValue = Result.Loading, this) {
withIOContext { withIOContext {
value = try { value = try {
val appInfo = context.packageManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) val appInfo = ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
val appResources = context.packageManager.getResourcesForApplication(appInfo) val appResources = context.packageManager.getResourcesForApplication(appInfo)
Result.Success( Result.Success(
appResources.getDrawableForDensity(appInfo.icon, density, null)!! appResources.getDrawableForDensity(appInfo.icon, density, null)!!

View file

@ -66,7 +66,10 @@ class ExtensionManager(
fun getAppIconForSource(sourceId: Long): Drawable? { fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
if (pkgName != null) { if (pkgName != null) {
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) } return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) {
ExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo
.loadIcon(context.packageManager)
}
} }
return null return null
} }
@ -333,6 +336,7 @@ class ExtensionManager(
} }
override fun onPackageUninstalled(pkgName: String) { override fun onPackageUninstalled(pkgName: String) {
ExtensionLoader.uninstallPrivateExtension(context, pkgName)
unregisterExtension(pkgName) unregisterExtension(pkgName)
updatePendingUpdatesCount() updatePendingUpdatesCount()
} }

View file

@ -32,6 +32,7 @@ sealed class Extension {
val hasUpdate: Boolean = false, val hasUpdate: Boolean = false,
val isObsolete: Boolean = false, val isObsolete: Boolean = false,
val isUnofficial: Boolean = false, val isUnofficial: Boolean = false,
val isShared: Boolean,
) : Extension() ) : Extension()
data class Available( data class Available(

View file

@ -4,6 +4,9 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.net.Uri
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineStart
@ -27,7 +30,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
* Registers this broadcast receiver * Registers this broadcast receiver
*/ */
fun register(context: Context) { fun register(context: Context) {
context.registerReceiver(this, filter) ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
} }
/** /**
@ -38,6 +41,9 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
addAction(Intent.ACTION_PACKAGE_ADDED) addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED) addAction(Intent.ACTION_PACKAGE_REPLACED)
addAction(Intent.ACTION_PACKAGE_REMOVED) addAction(Intent.ACTION_PACKAGE_REMOVED)
addAction(ACTION_EXTENSION_ADDED)
addAction(ACTION_EXTENSION_REPLACED)
addAction(ACTION_EXTENSION_REMOVED)
addDataScheme("package") addDataScheme("package")
} }
@ -49,7 +55,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
if (intent == null) return if (intent == null) return
when (intent.action) { when (intent.action) {
Intent.ACTION_PACKAGE_ADDED -> { Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
if (isReplacing(intent)) return if (isReplacing(intent)) return
launchNow { launchNow {
@ -60,7 +66,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
} }
} }
} }
Intent.ACTION_PACKAGE_REPLACED -> { Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
launchNow { launchNow {
when (val result = getExtensionFromIntent(context, intent)) { when (val result = getExtensionFromIntent(context, intent)) {
is LoadResult.Success -> listener.onExtensionUpdated(result.extension) is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
@ -70,7 +76,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
} }
} }
} }
Intent.ACTION_PACKAGE_REMOVED -> { Intent.ACTION_PACKAGE_REMOVED, ACTION_EXTENSION_REMOVED -> {
if (isReplacing(intent)) return if (isReplacing(intent)) return
val pkgName = getPackageNameFromIntent(intent) val pkgName = getPackageNameFromIntent(intent)
@ -121,4 +127,30 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
fun onExtensionUntrusted(extension: Extension.Untrusted) fun onExtensionUntrusted(extension: Extension.Untrusted)
fun onPackageUninstalled(pkgName: String) fun onPackageUninstalled(pkgName: String)
} }
companion object {
private const val ACTION_EXTENSION_ADDED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_ADDED"
private const val ACTION_EXTENSION_REPLACED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REPLACED"
private const val ACTION_EXTENSION_REMOVED = "${BuildConfig.APPLICATION_ID}.ACTION_EXTENSION_REMOVED"
fun notifyAdded(context: Context, pkgName: String) {
notify(context, pkgName, ACTION_EXTENSION_ADDED)
}
fun notifyReplaced(context: Context, pkgName: String) {
notify(context, pkgName, ACTION_EXTENSION_REPLACED)
}
fun notifyRemoved(context: Context, pkgName: String) {
notify(context, pkgName, ACTION_EXTENSION_REMOVED)
}
private fun notify(context: Context, pkgName: String, action: String) {
Intent(action).apply {
data = Uri.parse("package:$pkgName")
`package` = context.packageName
context.sendBroadcast(this)
}
}
}
} }

View file

@ -11,10 +11,12 @@ import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.installer.Installer import eu.kanade.tachiyomi.extension.installer.Installer
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.isPackageInstalled
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -156,6 +158,35 @@ internal class ExtensionInstaller(private val context: Context) {
context.startActivity(intent) context.startActivity(intent)
} }
BasePreferences.ExtensionInstaller.PRIVATE -> {
val extensionManager = Injekt.get<ExtensionManager>()
val tempFile = File(context.cacheDir, "temp_$downloadId")
if (tempFile.exists() && !tempFile.delete()) {
// Unlikely but just in case
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
return
}
try {
context.contentResolver.openInputStream(uri)?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
if (ExtensionLoader.installPrivateExtensionFile(context, tempFile)) {
extensionManager.updateInstallStep(downloadId, InstallStep.Installed)
} else {
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to read downloaded extension file." }
extensionManager.updateInstallStep(downloadId, InstallStep.Error)
}
tempFile.delete()
}
else -> { else -> {
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer) val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
@ -178,10 +209,15 @@ internal class ExtensionInstaller(private val context: Context) {
* @param pkgName The package name of the extension to uninstall * @param pkgName The package name of the extension to uninstall
*/ */
fun uninstallApk(pkgName: String) { fun uninstallApk(pkgName: String) {
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri()) if (context.isPackageInstalled(pkgName)) {
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @Suppress("DEPRECATION")
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
context.startActivity(intent) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} else {
ExtensionLoader.uninstallPrivateExtension(context, pkgName)
ExtensionInstallReceiver.notifyRemoved(context, pkgName)
}
} }
/** /**

View file

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.extension.util package eu.kanade.tachiyomi.extension.util
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build 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.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import eu.kanade.tachiyomi.util.system.getApplicationIcon
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import tachiyomi.core.util.system.logcat import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy 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 { internal object ExtensionLoader {
private val preferences: SourcePreferences by injectLazy() private val preferences: SourcePreferences by injectLazy()
@ -41,12 +52,11 @@ internal object ExtensionLoader {
const val LIB_VERSION_MIN = 1.4 const val LIB_VERSION_MIN = 1.4
const val LIB_VERSION_MAX = 1.5 const val LIB_VERSION_MAX = 1.5
private val PACKAGE_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @Suppress("DEPRECATION")
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNING_CERTIFICATES private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or
} else { PackageManager.GET_META_DATA or
@Suppress("DEPRECATION") PackageManager.GET_SIGNATURES or
PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0)
}
// inorichi's key // inorichi's key
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23"
@ -56,8 +66,57 @@ internal object ExtensionLoader {
*/ */
var trustedSignatures = mutableSetOf(officialSignature) + preferences.trustedSignatures().get() 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. * @param context The application context.
*/ */
@ -70,16 +129,43 @@ internal object ExtensionLoader {
pkgManager.getInstalledPackages(PACKAGE_FLAGS) 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() if (extPkgs.isEmpty()) return emptyList()
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return runBlocking { return runBlocking {
val deferred = extPkgs.map { 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. * contains the required feature flag before trying to load it.
*/ */
fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult { fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
val pkgInfo = try { val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
if (extensionPackage == null) {
logcat(LogPriority.ERROR) { "Extension package is not found ($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
}
val sharedPkg = try {
context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS) context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS)
.takeIf { isPackageAnExtension(it) }
?.let {
ExtensionInfo(
packageInfo = it,
isShared = true,
)
}
} catch (error: PackageManager.NameNotFoundException) { } catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point null
logcat(LogPriority.ERROR, error)
return LoadResult.Error
} }
if (!isPackageAnExtension(pkgInfo)) {
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" } return selectExtensionPackage(sharedPkg, privatePkg)
return LoadResult.Error
}
return loadExtension(context, pkgName, pkgInfo)
} }
/** /**
* Loads an extension given its package name. * Loads an extension
* *
* @param context The application context. * @param context The application context.
* @param pkgName The package name of the extension to load. * @param extensionInfo The extension to load.
* @param pkgInfo The package info of the extension.
*/ */
private fun loadExtension(context: Context, pkgName: String, pkgInfo: PackageInfo): LoadResult { private fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
val pkgManager = context.packageManager val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo
val appInfo = try { val appInfo = pkgInfo.applicationInfo
pkgManager.getApplicationInfo(pkgName, PackageManager.GET_META_DATA) val pkgName = pkgInfo.packageName
} catch (error: PackageManager.NameNotFoundException) {
// Unlikely, but the package may have been uninstalled at this point
logcat(LogPriority.ERROR, error)
return LoadResult.Error
}
val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
val versionName = pkgInfo.versionName val versionName = pkgInfo.versionName
@ -139,12 +249,19 @@ internal object ExtensionLoader {
return LoadResult.Error return LoadResult.Error
} }
val signatureHash = getSignatureHash(context, pkgInfo) val signatures = getSignatures(pkgInfo)
if (signatureHash == null) { if (signatures.isNullOrEmpty()) {
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
return LoadResult.Error return LoadResult.Error
} else if (signatureHash !in trustedSignatures) { } else if (!hasTrustedSignature(signatures)) {
val extension = Extension.Untrusted(extName, pkgName, versionName, versionCode, libVersion, signatureHash) val extension = Extension.Untrusted(
extName,
pkgName,
versionName,
versionCode,
libVersion,
signatures.last(),
)
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" } logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
return LoadResult.Untrusted(extension) return LoadResult.Untrusted(extension)
} }
@ -204,12 +321,35 @@ internal object ExtensionLoader {
hasChangelog = hasChangelog, hasChangelog = hasChangelog,
sources = sources, sources = sources,
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
isUnofficial = signatureHash != officialSignature, isUnofficial = !isOfficiallySigned(signatures),
icon = context.getApplicationIcon(pkgName), icon = appInfo.loadIcon(pkgManager),
isShared = extensionInfo.isShared,
) )
return LoadResult.Success(extension) 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. * 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. * @param pkgInfo The package info of the application.
* @return List SHA256 digest of the signatures
*/ */
private fun getSignatureHash(context: Context, pkgInfo: PackageInfo): String? { private fun getSignatures(pkgInfo: PackageInfo): List<String>? {
val signatures = PackageInfoCompat.getSignatures(context.packageManager, pkgInfo.packageName) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return signatures.firstOrNull()?.let { Hash.sha256(it.toByteArray()) } 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,
)
} }

View file

@ -314,6 +314,7 @@
<string name="ext_installer_legacy">Legacy</string> <string name="ext_installer_legacy">Legacy</string>
<string name="ext_installer_packageinstaller" translatable="false">PackageInstaller</string> <string name="ext_installer_packageinstaller" translatable="false">PackageInstaller</string>
<string name="ext_installer_shizuku" translatable="false">Shizuku</string> <string name="ext_installer_shizuku" translatable="false">Shizuku</string>
<string name="ext_installer_private" translatable="false">Private</string>
<string name="ext_installer_shizuku_stopped">Shizuku is not running</string> <string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
<string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string> <string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>