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
Cette révision appartient à :
Parent
7146913c71
révision
627f07408e
9 fichiers modifiés avec 323 ajouts et 70 suppressions
|
@ -24,5 +24,6 @@ class BasePreferences(
|
|||
LEGACY(R.string.ext_installer_legacy),
|
||||
PACKAGEINSTALLER(R.string.ext_installer_packageinstaller),
|
||||
SHIZUKU(R.string.ext_installer_shizuku),
|
||||
PRIVATE(R.string.ext_installer_private),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,12 +10,10 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
|
@ -176,7 +174,8 @@ private fun ExtensionDetails(
|
|||
data = Uri.fromParts("package", extension.pkgName, null)
|
||||
context.startActivity(this)
|
||||
}
|
||||
},
|
||||
Unit
|
||||
}.takeIf { extension.isShared },
|
||||
onClickAgeRating = {
|
||||
showNsfwWarning = true
|
||||
},
|
||||
|
@ -209,7 +208,7 @@ private fun DetailsHeader(
|
|||
extension: Extension,
|
||||
onClickAgeRating: () -> Unit,
|
||||
onClickUninstall: () -> Unit,
|
||||
onClickAppInfo: () -> Unit,
|
||||
onClickAppInfo: (() -> Unit)?,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
|
@ -293,6 +292,7 @@ private fun DetailsHeader(
|
|||
top = MaterialTheme.padding.small,
|
||||
bottom = MaterialTheme.padding.medium,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
|
@ -301,16 +301,16 @@ private fun DetailsHeader(
|
|||
Text(stringResource(R.string.ext_uninstall))
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClickAppInfo,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ext_app_info),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
if (onClickAppInfo != null) {
|
||||
Button(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClickAppInfo,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ext_app_info),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package eu.kanade.presentation.browse.components
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.compose.foundation.Image
|
||||
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.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||
import tachiyomi.core.util.lang.withIOContext
|
||||
import tachiyomi.domain.source.model.Source
|
||||
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) {
|
||||
withIOContext {
|
||||
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)
|
||||
Result.Success(
|
||||
appResources.getDrawableForDensity(appInfo.icon, density, null)!!
|
||||
|
|
|
@ -66,7 +66,10 @@ class ExtensionManager(
|
|||
fun getAppIconForSource(sourceId: Long): Drawable? {
|
||||
val pkgName = _installedExtensionsFlow.value.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
|
||||
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
|
||||
}
|
||||
|
@ -333,6 +336,7 @@ class ExtensionManager(
|
|||
}
|
||||
|
||||
override fun onPackageUninstalled(pkgName: String) {
|
||||
ExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||
unregisterExtension(pkgName)
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ sealed class Extension {
|
|||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isUnofficial: Boolean = false,
|
||||
val isShared: Boolean,
|
||||
) : Extension()
|
||||
|
||||
data class Available(
|
||||
|
|
|
@ -4,6 +4,9 @@ import android.content.BroadcastReceiver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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.LoadResult
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
|
@ -27,7 +30,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
|||
* Registers this broadcast receiver
|
||||
*/
|
||||
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_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||
addAction(ACTION_EXTENSION_ADDED)
|
||||
addAction(ACTION_EXTENSION_REPLACED)
|
||||
addAction(ACTION_EXTENSION_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
|
||||
|
@ -49,7 +55,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
|||
if (intent == null) return
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
Intent.ACTION_PACKAGE_ADDED, ACTION_EXTENSION_ADDED -> {
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
launchNow {
|
||||
|
@ -60,7 +66,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
|||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
|
||||
launchNow {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
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
|
||||
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
|
@ -121,4 +127,30 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
|||
fun onExtensionUntrusted(extension: Extension.Untrusted)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,10 +11,12 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.installer.Installer
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.isPackageInstalled
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
@ -156,6 +158,35 @@ internal class ExtensionInstaller(private val context: Context) {
|
|||
|
||||
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 -> {
|
||||
val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer)
|
||||
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
|
||||
*/
|
||||
fun uninstallApk(pkgName: String) {
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
context.startActivity(intent)
|
||||
if (context.isPackageInstalled(pkgName)) {
|
||||
@Suppress("DEPRECATION")
|
||||
val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri())
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
ExtensionLoader.uninstallPrivateExtension(context, pkgName)
|
||||
ExtensionInstallReceiver.notifyRemoved(context, pkgName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 {
|
||||
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)
|
||||
.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
ExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = true,
|
||||
)
|
||||
}
|
||||
} catch (error: PackageManager.NameNotFoundException) {
|
||||
// Unlikely, but the package may have been uninstalled at this point
|
||||
logcat(LogPriority.ERROR, error)
|
||||
return LoadResult.Error
|
||||
null
|
||||
}
|
||||
if (!isPackageAnExtension(pkgInfo)) {
|
||||
logcat(LogPriority.WARN) { "Tried to load a package that wasn't a extension ($pkgName)" }
|
||||
return LoadResult.Error
|
||||
}
|
||||
return loadExtension(context, pkgName, pkgInfo)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -314,6 +314,7 @@
|
|||
<string name="ext_installer_legacy">Legacy</string>
|
||||
<string name="ext_installer_packageinstaller" translatable="false">PackageInstaller</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_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
|
||||
|
||||
|
|
Référencer dans un nouveau ticket