Expose installed extensions that failed to load (#3953)
This commit is contained in:
@@ -12,7 +12,7 @@ class GetExtensionSources(
|
||||
private val preferences: SourcePreferences,
|
||||
) {
|
||||
|
||||
fun subscribe(extension: Extension.Installed): Flow<List<ExtensionSourceItem>> {
|
||||
fun subscribe(extension: Extension.Loaded): Flow<List<ExtensionSourceItem>> {
|
||||
val isMultiSource = extension.sources.size > 1
|
||||
val isMultiLangSingleSource =
|
||||
isMultiSource && extension.sources.map { it.name }.distinct().size == 1
|
||||
|
||||
@@ -19,24 +19,24 @@ class GetExtensionsByType(
|
||||
|
||||
return combine(
|
||||
preferences.enabledLanguages.changes(),
|
||||
extensionManager.installedExtensionsFlow,
|
||||
extensionManager.untrustedExtensionsFlow,
|
||||
extensionManager.loadedExtensionsFlow,
|
||||
extensionManager.notLoadedExtensionsFlow,
|
||||
extensionManager.availableExtensionsFlow,
|
||||
) { enabledLanguages, _installed, _untrusted, _available ->
|
||||
val (updates, installed) = _installed
|
||||
) { enabledLanguages, _loaded, _notLoaded, _available ->
|
||||
val (updates, loaded) = _loaded
|
||||
.sortedWith(
|
||||
compareBy<Extension.Installed> { !it.isObsolete }
|
||||
compareBy<Extension.Loaded> { !it.isObsolete }
|
||||
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
|
||||
)
|
||||
.partition { it.hasUpdate }
|
||||
|
||||
val untrusted = _untrusted
|
||||
val notLoaded = _notLoaded
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
|
||||
|
||||
val available = _available
|
||||
.filter { extension ->
|
||||
_installed.none { it.pkgName == extension.pkgName } &&
|
||||
_untrusted.none { it.pkgName == extension.pkgName } &&
|
||||
_loaded.none { it.pkgName == extension.pkgName } &&
|
||||
_notLoaded.none { it.pkgName == extension.pkgName } &&
|
||||
extension.contentWarning in enabledContentWarnings
|
||||
}
|
||||
.flatMap { ext ->
|
||||
@@ -52,7 +52,7 @@ class GetExtensionsByType(
|
||||
}
|
||||
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
|
||||
|
||||
Extensions(updates, installed, available, untrusted)
|
||||
Extensions(updates, loaded, available, notLoaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package eu.kanade.domain.extension.model
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
|
||||
data class Extensions(
|
||||
val updates: List<Extension.Installed>,
|
||||
val installed: List<Extension.Installed>,
|
||||
val updates: List<Extension.Loaded>,
|
||||
val loaded: List<Extension.Loaded>,
|
||||
val available: List<Extension.Available>,
|
||||
val untrusted: List<Extension.Untrusted>,
|
||||
val notLoaded: List<Extension.NotLoaded>,
|
||||
)
|
||||
|
||||
@@ -145,7 +145,7 @@ fun ExtensionDetailsScreen(
|
||||
@Composable
|
||||
private fun ExtensionDetails(
|
||||
contentPadding: PaddingValues,
|
||||
extension: Extension.Installed,
|
||||
extension: Extension.Loaded,
|
||||
sources: List<ExtensionSourceItem>,
|
||||
incognitoMode: Boolean,
|
||||
onClickSourcePreferences: (sourceId: Long) -> Unit,
|
||||
@@ -239,7 +239,7 @@ private fun DetailsHeader(
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
if (extension is Extension.Installed) {
|
||||
if (extension is Extension.Loaded) {
|
||||
append("\n\n")
|
||||
appendLine(
|
||||
"""
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
@@ -48,14 +49,17 @@ import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel
|
||||
import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsViewModel
|
||||
import eu.kanade.tachiyomi.util.system.LocaleHelper
|
||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
|
||||
import mihon.icons.materialsymbols.MaterialSymbols
|
||||
import mihon.icons.materialsymbols.rounded.Close
|
||||
import mihon.icons.materialsymbols.rounded.Download
|
||||
import mihon.icons.materialsymbols.rounded.Info
|
||||
import mihon.icons.materialsymbols.rounded.Public
|
||||
import mihon.icons.materialsymbols.rounded.Refresh
|
||||
import mihon.icons.materialsymbols.rounded.Settings
|
||||
import mihon.icons.materialsymbols.rounded.VerifiedUser
|
||||
import tachiyomi.core.common.i18n.stringResource
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.FastScrollLazyColumn
|
||||
import tachiyomi.presentation.core.components.material.PullRefresh
|
||||
@@ -78,10 +82,10 @@ fun ExtensionScreen(
|
||||
onClickItemCancel: (Extension) -> Unit,
|
||||
onOpenWebView: (Extension.Available) -> Unit,
|
||||
onInstallExtension: (Extension.Available) -> Unit,
|
||||
onUninstallExtension: (Extension) -> Unit,
|
||||
onUpdateExtension: (Extension.Installed) -> Unit,
|
||||
onTrustExtension: (Extension.Untrusted) -> Unit,
|
||||
onOpenExtension: (Extension.Installed) -> Unit,
|
||||
onUninstallExtension: (Extension.Installed) -> Unit,
|
||||
onUpdateExtension: (Extension.Loaded) -> Unit,
|
||||
onTrustExtension: (Extension.NotLoaded) -> Unit,
|
||||
onOpenExtension: (Extension.Loaded) -> Unit,
|
||||
onClickUpdateAll: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
@@ -139,14 +143,14 @@ private fun ExtensionContent(
|
||||
onClickItemCancel: (Extension) -> Unit,
|
||||
onOpenWebView: (Extension.Available) -> Unit,
|
||||
onInstallExtension: (Extension.Available) -> Unit,
|
||||
onUninstallExtension: (Extension) -> Unit,
|
||||
onUpdateExtension: (Extension.Installed) -> Unit,
|
||||
onTrustExtension: (Extension.Untrusted) -> Unit,
|
||||
onOpenExtension: (Extension.Installed) -> Unit,
|
||||
onUninstallExtension: (Extension.Installed) -> Unit,
|
||||
onUpdateExtension: (Extension.Loaded) -> Unit,
|
||||
onTrustExtension: (Extension.NotLoaded) -> Unit,
|
||||
onOpenExtension: (Extension.Loaded) -> Unit,
|
||||
onClickUpdateAll: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var trustState by remember { mutableStateOf<Extension.Untrusted?>(null) }
|
||||
var notLoadedState by remember { mutableStateOf<Extension.NotLoaded?>(null) }
|
||||
val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true)
|
||||
|
||||
FastScrollLazyColumn(
|
||||
@@ -205,8 +209,8 @@ private fun ExtensionContent(
|
||||
contentType = { "item" },
|
||||
key = { item ->
|
||||
when (item.extension) {
|
||||
is Extension.Untrusted -> "extension-untrusted-${item.hashCode()}"
|
||||
is Extension.Installed -> "extension-installed-${item.hashCode()}"
|
||||
is Extension.NotLoaded -> "extension-not-loaded-${item.hashCode()}"
|
||||
is Extension.Loaded -> "extension-loaded-${item.hashCode()}"
|
||||
is Extension.Available -> "extension-available-${item.hashCode()}"
|
||||
}
|
||||
},
|
||||
@@ -217,9 +221,9 @@ private fun ExtensionContent(
|
||||
onClickItem = {
|
||||
when (it) {
|
||||
is Extension.Available -> onInstallExtension(it)
|
||||
is Extension.Installed -> onOpenExtension(it)
|
||||
is Extension.Untrusted -> {
|
||||
trustState = it
|
||||
is Extension.Loaded -> onOpenExtension(it)
|
||||
is Extension.NotLoaded -> {
|
||||
notLoadedState = it
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -227,7 +231,7 @@ private fun ExtensionContent(
|
||||
onClickItemSecondaryAction = {
|
||||
when (it) {
|
||||
is Extension.Available -> onOpenWebView(it)
|
||||
is Extension.Installed -> onOpenExtension(it)
|
||||
is Extension.Loaded -> onOpenExtension(it)
|
||||
else -> {}
|
||||
}
|
||||
},
|
||||
@@ -235,15 +239,15 @@ private fun ExtensionContent(
|
||||
onClickItemAction = {
|
||||
when (it) {
|
||||
is Extension.Available -> onInstallExtension(it)
|
||||
is Extension.Installed -> {
|
||||
is Extension.Loaded -> {
|
||||
if (it.hasUpdate) {
|
||||
onUpdateExtension(it)
|
||||
} else {
|
||||
onOpenExtension(it)
|
||||
}
|
||||
}
|
||||
is Extension.Untrusted -> {
|
||||
trustState = it
|
||||
is Extension.NotLoaded -> {
|
||||
notLoadedState = it
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -251,20 +255,30 @@ private fun ExtensionContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trustState != null) {
|
||||
notLoadedState?.let { extension ->
|
||||
val dismiss = { notLoadedState = null }
|
||||
if (extension.reason is Extension.NotLoaded.Reason.Untrusted) {
|
||||
ExtensionTrustDialog(
|
||||
onClickConfirm = {
|
||||
onTrustExtension(trustState!!)
|
||||
trustState = null
|
||||
onTrustExtension(extension)
|
||||
dismiss()
|
||||
},
|
||||
onClickDismiss = {
|
||||
onUninstallExtension(trustState!!)
|
||||
trustState = null
|
||||
},
|
||||
onDismissRequest = {
|
||||
trustState = null
|
||||
onUninstallExtension(extension)
|
||||
dismiss()
|
||||
},
|
||||
onDismissRequest = dismiss,
|
||||
)
|
||||
} else {
|
||||
ExtensionNotLoadedDialog(
|
||||
reason = extension.reason,
|
||||
onClickUninstall = {
|
||||
onUninstallExtension(extension)
|
||||
dismiss()
|
||||
},
|
||||
onDismissRequest = dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +367,7 @@ private fun ExtensionItemContent(
|
||||
) {
|
||||
ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
|
||||
var hasAlreadyShownAnElement by remember { mutableStateOf(false) }
|
||||
if (extension is Extension.Installed && extension.lang.isNotEmpty()) {
|
||||
if (extension is Extension.Loaded && extension.lang.isNotEmpty()) {
|
||||
hasAlreadyShownAnElement = true
|
||||
Text(
|
||||
text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current),
|
||||
@@ -370,9 +384,9 @@ private fun ExtensionItemContent(
|
||||
|
||||
val warnings = listOfNotNull(
|
||||
when {
|
||||
extension is Extension.Untrusted ->
|
||||
MR.strings.ext_untrusted to MaterialTheme.colorScheme.error
|
||||
extension is Extension.Installed && extension.isObsolete ->
|
||||
extension is Extension.NotLoaded ->
|
||||
extension.reason.labelRes?.let { it to MaterialTheme.colorScheme.error }
|
||||
extension is Extension.Loaded && extension.isObsolete ->
|
||||
MR.strings.ext_obsolete to MaterialTheme.colorScheme.error
|
||||
else -> null
|
||||
},
|
||||
@@ -389,7 +403,7 @@ private fun ExtensionItemContent(
|
||||
)
|
||||
}
|
||||
|
||||
if (extension is Extension.Installed && !extension.isShared) {
|
||||
if (extension is Extension.Loaded && !extension.isShared) {
|
||||
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
|
||||
Text(
|
||||
text = stringResource(MR.strings.ext_installer_private),
|
||||
@@ -446,7 +460,7 @@ private fun ExtensionItemActions(
|
||||
}
|
||||
installStep == InstallStep.Idle -> {
|
||||
when (extension) {
|
||||
is Extension.Installed -> {
|
||||
is Extension.Loaded -> {
|
||||
IconButton(onClick = { onClickItemSecondaryAction(extension) }) {
|
||||
Icon(
|
||||
imageVector = MaterialSymbols.Rounded.Settings,
|
||||
@@ -463,11 +477,20 @@ private fun ExtensionItemActions(
|
||||
}
|
||||
}
|
||||
}
|
||||
is Extension.Untrusted -> {
|
||||
is Extension.NotLoaded -> {
|
||||
val isUntrusted = extension.reason is Extension.NotLoaded.Reason.Untrusted
|
||||
IconButton(onClick = { onClickItemAction(extension) }) {
|
||||
Icon(
|
||||
imageVector = MaterialSymbols.Rounded.VerifiedUser,
|
||||
contentDescription = stringResource(MR.strings.ext_trust),
|
||||
imageVector = if (isUntrusted) {
|
||||
MaterialSymbols.Rounded.VerifiedUser
|
||||
} else {
|
||||
MaterialSymbols.Rounded.Info
|
||||
},
|
||||
contentDescription = if (isUntrusted) {
|
||||
stringResource(MR.strings.ext_trust)
|
||||
} else {
|
||||
stringResource(MR.strings.ext_not_loaded)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -530,6 +553,83 @@ private fun ExtensionHeader(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the reasons the user can act on are worth naming in the row; the rest all mean "broken" to
|
||||
* them and are spelled out in [ExtensionNotLoadedDialog] instead.
|
||||
*/
|
||||
private val Extension.NotLoaded.Reason.labelRes: StringResource?
|
||||
get() = when (this) {
|
||||
is Extension.NotLoaded.Reason.Untrusted -> MR.strings.ext_untrusted
|
||||
Extension.NotLoaded.Reason.Filtered -> MR.strings.ext_filtered
|
||||
// The section header already says these aren't loaded; the dialog says why
|
||||
Extension.NotLoaded.Reason.Unsigned,
|
||||
Extension.NotLoaded.Reason.UnsupportedLibVersion,
|
||||
Extension.NotLoaded.Reason.Malformed,
|
||||
is Extension.NotLoaded.Reason.Failed,
|
||||
-> null
|
||||
}
|
||||
|
||||
private val Extension.NotLoaded.Reason.descriptionRes: StringResource
|
||||
get() = when (this) {
|
||||
is Extension.NotLoaded.Reason.Untrusted -> MR.strings.untrusted_extension_message
|
||||
Extension.NotLoaded.Reason.Filtered -> MR.strings.ext_filtered_message
|
||||
Extension.NotLoaded.Reason.Unsigned -> MR.strings.ext_unsigned_message
|
||||
Extension.NotLoaded.Reason.UnsupportedLibVersion -> MR.strings.ext_unsupported_message
|
||||
Extension.NotLoaded.Reason.Malformed -> MR.strings.ext_malformed_message
|
||||
is Extension.NotLoaded.Reason.Failed -> MR.strings.ext_load_failed_message
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionNotLoadedDialog(
|
||||
reason: Extension.NotLoaded.Reason,
|
||||
onClickUninstall: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
title = {
|
||||
Text(text = stringResource(MR.strings.ext_not_loaded_dialog))
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
|
||||
Text(text = stringResource(reason.descriptionRes))
|
||||
|
||||
if (reason is Extension.NotLoaded.Reason.Failed) {
|
||||
Text(
|
||||
text = reason.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.copyToClipboard(
|
||||
label = context.stringResource(MR.strings.ext_copy_stacktrace),
|
||||
content = reason.stackTrace,
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Text(text = stringResource(MR.strings.ext_copy_stacktrace))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(text = stringResource(MR.strings.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onClickUninstall) {
|
||||
Text(text = stringResource(MR.strings.ext_uninstall))
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionTrustDialog(
|
||||
onClickConfirm: () -> Unit,
|
||||
|
||||
@@ -96,7 +96,7 @@ fun ExtensionIcon(
|
||||
.clip(MaterialTheme.shapes.extraSmall),
|
||||
)
|
||||
}
|
||||
is Extension.Installed -> {
|
||||
is Extension.Loaded -> {
|
||||
val icon by extension.getIcon(density)
|
||||
when (icon) {
|
||||
Result.Loading -> Box(modifier = modifier)
|
||||
@@ -112,7 +112,7 @@ fun ExtensionIcon(
|
||||
)
|
||||
}
|
||||
}
|
||||
is Extension.Untrusted -> Image(
|
||||
is Extension.NotLoaded -> Image(
|
||||
imageVector = MaterialSymbols.Rounded.Dangerous,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error),
|
||||
|
||||
@@ -11,7 +11,6 @@ import eu.kanade.tachiyomi.extension.api.ExtensionApi
|
||||
import eu.kanade.tachiyomi.extension.api.ExtensionUpdateNotifier
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.InstallStep
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionInstallReceiver
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||
@@ -60,14 +59,14 @@ class ExtensionManager(
|
||||
|
||||
private val iconMap = mutableMapOf<String, Drawable>()
|
||||
|
||||
private val installedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Installed>())
|
||||
val installedExtensionsFlow = installedExtensionMapFlow.mapExtensionsWhenInitialized()
|
||||
private val loadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Loaded>())
|
||||
val loadedExtensionsFlow = loadedExtensionMapFlow.mapExtensionsWhenInitialized()
|
||||
|
||||
private val availableExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Available>())
|
||||
val availableExtensionsFlow = availableExtensionMapFlow.mapExtensions(scope)
|
||||
|
||||
private val untrustedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.Untrusted>())
|
||||
val untrustedExtensionsFlow = untrustedExtensionMapFlow.mapExtensionsWhenInitialized()
|
||||
private val notLoadedExtensionMapFlow = MutableStateFlow(emptyMap<String, Extension.NotLoaded>())
|
||||
val notLoadedExtensionsFlow = notLoadedExtensionMapFlow.mapExtensionsWhenInitialized()
|
||||
|
||||
init {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -78,20 +77,25 @@ class ExtensionManager(
|
||||
|
||||
private var subLanguagesEnabledOnFirstRun = preferences.enabledLanguages.isSet()
|
||||
|
||||
suspend fun getInstalledExtensions(): List<Extension.Installed> {
|
||||
suspend fun getLoadedExtensions(): List<Extension.Loaded> {
|
||||
initialized.await()
|
||||
return installedExtensionMapFlow.value.values.toList()
|
||||
return loadedExtensionMapFlow.value.values.toList()
|
||||
}
|
||||
|
||||
suspend fun getNotLoadedExtensions(): List<Extension.NotLoaded> {
|
||||
initialized.await()
|
||||
return notLoadedExtensionMapFlow.value.values.toList()
|
||||
}
|
||||
|
||||
suspend fun getExtensionPackage(sourceId: Long): String? {
|
||||
return getInstalledExtensions().find { extension ->
|
||||
return getLoadedExtensions().find { extension ->
|
||||
extension.sources.any { it.id == sourceId }
|
||||
}
|
||||
?.pkgName
|
||||
}
|
||||
|
||||
fun getExtensionPackageAsFlow(sourceId: Long): Flow<String?> {
|
||||
return installedExtensionsFlow.map { extensions ->
|
||||
return loadedExtensionsFlow.map { extensions ->
|
||||
extensions.find { extension ->
|
||||
extension.sources.any { it.id == sourceId }
|
||||
}
|
||||
@@ -126,13 +130,13 @@ class ExtensionManager(
|
||||
try {
|
||||
val extensions = ExtensionLoader.loadExtensions(context)
|
||||
|
||||
installedExtensionMapFlow.value = extensions
|
||||
.filterIsInstance<LoadResult.Success>()
|
||||
.associate { it.extension.pkgName to it.extension }
|
||||
loadedExtensionMapFlow.value = extensions
|
||||
.filterIsInstance<Extension.Loaded>()
|
||||
.associateBy { it.pkgName }
|
||||
|
||||
untrustedExtensionMapFlow.value = extensions
|
||||
.filterIsInstance<LoadResult.Untrusted>()
|
||||
.associate { it.extension.pkgName to it.extension }
|
||||
notLoadedExtensionMapFlow.value = extensions
|
||||
.filterIsInstance<Extension.NotLoaded>()
|
||||
.associateBy { it.pkgName }
|
||||
|
||||
initialized.complete(Unit)
|
||||
} catch (e: Throwable) {
|
||||
@@ -202,23 +206,23 @@ class ExtensionManager(
|
||||
return
|
||||
}
|
||||
|
||||
val installedExtensionsMap = installedExtensionMapFlow.value.toMutableMap()
|
||||
val loadedExtensionsMap = loadedExtensionMapFlow.value.toMutableMap()
|
||||
var changed = false
|
||||
for ((pkgName, extension) in installedExtensionsMap) {
|
||||
for ((pkgName, extension) in loadedExtensionsMap) {
|
||||
val availableExt = availableExtensions.find { it.pkgName == pkgName }
|
||||
|
||||
if (availableExt == null && !extension.isObsolete) {
|
||||
installedExtensionsMap[pkgName] = extension.copy(isObsolete = true)
|
||||
loadedExtensionsMap[pkgName] = extension.copy(isObsolete = true)
|
||||
changed = true
|
||||
} else if (availableExt != null) {
|
||||
val hasUpdate = extension.updateExists(availableExt)
|
||||
if (extension.hasUpdate != hasUpdate) {
|
||||
installedExtensionsMap[pkgName] = extension.copy(
|
||||
loadedExtensionsMap[pkgName] = extension.copy(
|
||||
hasUpdate = hasUpdate,
|
||||
store = availableExt.store,
|
||||
)
|
||||
} else {
|
||||
installedExtensionsMap[pkgName] = extension.copy(
|
||||
loadedExtensionsMap[pkgName] = extension.copy(
|
||||
store = availableExt.store,
|
||||
)
|
||||
}
|
||||
@@ -226,7 +230,7 @@ class ExtensionManager(
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
installedExtensionMapFlow.value = installedExtensionsMap
|
||||
loadedExtensionMapFlow.value = loadedExtensionsMap
|
||||
}
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
@@ -249,7 +253,7 @@ class ExtensionManager(
|
||||
*
|
||||
* @param extension The extension to be updated.
|
||||
*/
|
||||
fun updateExtension(extension: Extension.Installed): Flow<InstallStep> {
|
||||
fun updateExtension(extension: Extension.Loaded): Flow<InstallStep> {
|
||||
val availableExt = availableExtensionMapFlow.value[extension.pkgName] ?: return emptyFlow()
|
||||
val isUpdateForPrivatelyInstalled = !extension.isShared
|
||||
return installer.downloadAndInstall(availableExt.apkUrl, availableExt, isUpdateForPrivatelyInstalled)
|
||||
@@ -277,7 +281,7 @@ class ExtensionManager(
|
||||
*
|
||||
* @param extension The extension to uninstall.
|
||||
*/
|
||||
fun uninstallExtension(extension: Extension) {
|
||||
fun uninstallExtension(extension: Extension.Installed) {
|
||||
installer.uninstallApk(extension.pkgName)
|
||||
}
|
||||
|
||||
@@ -287,16 +291,19 @@ class ExtensionManager(
|
||||
*
|
||||
* @param extension the extension to trust
|
||||
*/
|
||||
suspend fun trust(extension: Extension.Untrusted) {
|
||||
untrustedExtensionMapFlow.value[extension.pkgName] ?: return
|
||||
suspend fun trust(extension: Extension.NotLoaded) {
|
||||
val reason = extension.reason as? Extension.NotLoaded.Reason.Untrusted ?: return
|
||||
notLoadedExtensionMapFlow.value[extension.pkgName] ?: return
|
||||
|
||||
trustExtension.trust(extension.pkgName, extension.versionCode, extension.signatureHash)
|
||||
trustExtension.trust(extension.pkgName, extension.versionCode, reason.signatureHash)
|
||||
|
||||
untrustedExtensionMapFlow.value -= extension.pkgName
|
||||
notLoadedExtensionMapFlow.value -= extension.pkgName
|
||||
|
||||
ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)
|
||||
.let { it as? LoadResult.Success }
|
||||
?.let { registerNewExtension(it.extension) }
|
||||
when (val reloaded = ExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName)) {
|
||||
is Extension.Loaded -> registerExtension(reloaded)
|
||||
is Extension.NotLoaded -> notLoadedExtensionMapFlow.value += reloaded
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,18 +311,8 @@ class ExtensionManager(
|
||||
*
|
||||
* @param extension The extension to be registered.
|
||||
*/
|
||||
private fun registerNewExtension(extension: Extension.Installed) {
|
||||
installedExtensionMapFlow.value += extension
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given updated extension in this and the source managers previously removing
|
||||
* the outdated ones.
|
||||
*
|
||||
* @param extension The extension to be registered.
|
||||
*/
|
||||
private fun registerUpdatedExtension(extension: Extension.Installed) {
|
||||
installedExtensionMapFlow.value += extension
|
||||
private fun registerExtension(extension: Extension.Loaded) {
|
||||
loadedExtensionMapFlow.value += extension
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,8 +322,8 @@ class ExtensionManager(
|
||||
* @param pkgName The package name of the uninstalled application.
|
||||
*/
|
||||
private fun unregisterExtension(pkgName: String) {
|
||||
installedExtensionMapFlow.value -= pkgName
|
||||
untrustedExtensionMapFlow.value -= pkgName
|
||||
loadedExtensionMapFlow.value -= pkgName
|
||||
notLoadedExtensionMapFlow.value -= pkgName
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,19 +331,15 @@ class ExtensionManager(
|
||||
*/
|
||||
private inner class InstallationListener : ExtensionInstallReceiver.Listener {
|
||||
|
||||
override fun onExtensionInstalled(extension: Extension.Installed) {
|
||||
registerNewExtension(extension.withUpdateCheck())
|
||||
override fun onExtensionLoaded(extension: Extension.Loaded) {
|
||||
registerExtension(extension.withUpdateCheck())
|
||||
notLoadedExtensionMapFlow.value -= extension.pkgName
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onExtensionUpdated(extension: Extension.Installed) {
|
||||
registerUpdatedExtension(extension.withUpdateCheck())
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
override fun onExtensionUntrusted(extension: Extension.Untrusted) {
|
||||
installedExtensionMapFlow.value -= extension.pkgName
|
||||
untrustedExtensionMapFlow.value += extension
|
||||
override fun onExtensionNotLoaded(extension: Extension.NotLoaded) {
|
||||
loadedExtensionMapFlow.value -= extension.pkgName
|
||||
notLoadedExtensionMapFlow.value += extension
|
||||
updatePendingUpdatesCount()
|
||||
}
|
||||
|
||||
@@ -360,7 +353,7 @@ class ExtensionManager(
|
||||
/**
|
||||
* Extension method to set the update field of an installed extension.
|
||||
*/
|
||||
private fun Extension.Installed.withUpdateCheck(): Extension.Installed {
|
||||
private fun Extension.Loaded.withUpdateCheck(): Extension.Loaded {
|
||||
return if (updateExists()) {
|
||||
copy(hasUpdate = true)
|
||||
} else {
|
||||
@@ -368,7 +361,7 @@ class ExtensionManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Extension.Installed.updateExists(availableExtension: Extension.Available? = null): Boolean {
|
||||
private fun Extension.Loaded.updateExists(availableExtension: Extension.Available? = null): Boolean {
|
||||
val availableExt = availableExtension
|
||||
?: availableExtensionMapFlow.value[pkgName]
|
||||
?: return false
|
||||
@@ -377,7 +370,7 @@ class ExtensionManager(
|
||||
}
|
||||
|
||||
private fun updatePendingUpdatesCount() {
|
||||
val pendingUpdateCount = installedExtensionMapFlow.value.values.count { it.hasUpdate }
|
||||
val pendingUpdateCount = loadedExtensionMapFlow.value.values.count { it.hasUpdate }
|
||||
preferences.extensionUpdatesCount.set(pendingUpdateCount)
|
||||
if (pendingUpdateCount == 0) {
|
||||
extensionUpdateNotifier.dismiss()
|
||||
|
||||
@@ -5,7 +5,6 @@ import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||
import mihon.domain.extension.interactor.UpdateExtensionStores
|
||||
import mihon.domain.extension.repository.ExtensionStoreRepository
|
||||
@@ -28,12 +27,11 @@ class ExtensionApi(
|
||||
|
||||
val extensions = findExtensions()
|
||||
|
||||
val installedExtensions = ExtensionLoader.loadExtensions(context)
|
||||
.filterIsInstance<LoadResult.Success>()
|
||||
.map { it.extension }
|
||||
val loadedExtensions = ExtensionLoader.loadExtensions(context)
|
||||
.filterIsInstance<Extension.Loaded>()
|
||||
|
||||
val extensionsWithUpdate = mutableListOf<Extension.Installed>()
|
||||
for (installedExt in installedExtensions) {
|
||||
val extensionsWithUpdate = mutableListOf<Extension.Loaded>()
|
||||
for (installedExt in loadedExtensions) {
|
||||
val pkgName = installedExt.pkgName
|
||||
val availableExt = extensions.find { it.pkgName == pkgName } ?: continue
|
||||
val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package eu.kanade.tachiyomi.extension.model
|
||||
|
||||
sealed interface LoadResult {
|
||||
data class Success(val extension: Extension.Installed) : LoadResult
|
||||
data class Untrusted(val extension: Extension.Untrusted) : LoadResult
|
||||
data object Error : LoadResult
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -51,19 +50,19 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc
|
||||
if (isReplacing(intent)) return
|
||||
|
||||
scope.launch {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is LoadResult.Success -> listener.onExtensionInstalled(result.extension)
|
||||
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||
else -> {}
|
||||
when (val extension = getExtensionFromIntent(context, intent)) {
|
||||
is Extension.Loaded -> listener.onExtensionLoaded(extension)
|
||||
is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED, ACTION_EXTENSION_REPLACED -> {
|
||||
scope.launch {
|
||||
when (val result = getExtensionFromIntent(context, intent)) {
|
||||
is LoadResult.Success -> listener.onExtensionUpdated(result.extension)
|
||||
is LoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension)
|
||||
else -> {}
|
||||
when (val extension = getExtensionFromIntent(context, intent)) {
|
||||
is Extension.Loaded -> listener.onExtensionLoaded(extension)
|
||||
is Extension.NotLoaded -> listener.onExtensionNotLoaded(extension)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,11 +92,11 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc
|
||||
* @param context The application context.
|
||||
* @param intent The intent containing the package name of the extension.
|
||||
*/
|
||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
|
||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): Extension.Installed? {
|
||||
val pkgName = getPackageNameFromIntent(intent)
|
||||
if (pkgName == null) {
|
||||
logcat(LogPriority.WARN) { "Package name not found" }
|
||||
return LoadResult.Error
|
||||
return null
|
||||
}
|
||||
return ExtensionLoader.loadExtensionFromPkgName(context, pkgName)
|
||||
}
|
||||
@@ -113,9 +112,8 @@ internal class ExtensionInstallReceiver(private val listener: Listener) : Broadc
|
||||
* Listener that receives extension installation events.
|
||||
*/
|
||||
interface Listener {
|
||||
fun onExtensionInstalled(extension: Extension.Installed)
|
||||
fun onExtensionUpdated(extension: Extension.Installed)
|
||||
fun onExtensionUntrusted(extension: Extension.Untrusted)
|
||||
fun onExtensionLoaded(extension: Extension.Loaded)
|
||||
fun onExtensionNotLoaded(extension: Extension.NotLoaded)
|
||||
fun onPackageUninstalled(pkgName: String)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import eu.kanade.domain.extension.interactor.TrustExtension
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.util.lang.Hash
|
||||
@@ -112,7 +111,7 @@ internal object ExtensionLoader {
|
||||
*
|
||||
* @param context The application context.
|
||||
*/
|
||||
fun loadExtensions(context: Context): List<LoadResult> {
|
||||
fun loadExtensions(context: Context): List<Extension.Installed> {
|
||||
val pkgManager = context.packageManager
|
||||
|
||||
val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
@@ -138,7 +137,7 @@ internal object ExtensionLoader {
|
||||
|
||||
val path = it.absolutePath
|
||||
pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS)
|
||||
?.apply { applicationInfo!!.fixBasePaths(path) }
|
||||
?.also { pkg -> pkg.applicationInfo?.fixBasePaths(path) }
|
||||
}
|
||||
?.filter { isPackageAnExtension(it) }
|
||||
?.map { ExtensionInfo(packageInfo = it, isShared = false) }
|
||||
@@ -160,7 +159,7 @@ internal object ExtensionLoader {
|
||||
// Load each extension concurrently and wait for completion
|
||||
return runBlocking(Dispatchers.IO) {
|
||||
val deferred = extPkgs.map {
|
||||
async { loadExtension(context, it) }
|
||||
async { loadExtensionCatching(context, it) }
|
||||
}
|
||||
deferred.awaitAll()
|
||||
}
|
||||
@@ -170,13 +169,13 @@ internal object ExtensionLoader {
|
||||
* Attempts to load an extension from the given package name. It checks if the extension
|
||||
* contains the required feature flag before trying to load it.
|
||||
*/
|
||||
suspend fun loadExtensionFromPkgName(context: Context, pkgName: String): LoadResult {
|
||||
suspend fun loadExtensionFromPkgName(context: Context, pkgName: String): Extension.Installed? {
|
||||
val extensionPackage = getExtensionInfoFromPkgName(context, pkgName)
|
||||
if (extensionPackage == null) {
|
||||
logcat(LogPriority.ERROR) { "Extension package is not found ($pkgName)" }
|
||||
return LoadResult.Error
|
||||
return null
|
||||
}
|
||||
return loadExtension(context, extensionPackage)
|
||||
return loadExtensionCatching(context, extensionPackage)
|
||||
}
|
||||
|
||||
fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? {
|
||||
@@ -189,7 +188,7 @@ internal object ExtensionLoader {
|
||||
context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS)
|
||||
?.takeIf { isPackageAnExtension(it) }
|
||||
?.let {
|
||||
it.applicationInfo!!.fixBasePaths(privateExtensionFile.absolutePath)
|
||||
it.applicationInfo?.fixBasePaths(privateExtensionFile.absolutePath)
|
||||
ExtensionInfo(
|
||||
packageInfo = it,
|
||||
isShared = false,
|
||||
@@ -215,13 +214,36 @@ internal object ExtensionLoader {
|
||||
return selectExtensionPackage(sharedPkg, privatePkg)
|
||||
}
|
||||
|
||||
/**
|
||||
* [loadExtension] reports the failures it knows how to name, but an apk can be malformed in
|
||||
* ways it doesn't check for. Keep anything unforeseen to the extension that caused it instead of
|
||||
* letting it take down the load of every other extension.
|
||||
*/
|
||||
private suspend fun loadExtensionCatching(context: Context, extensionInfo: ExtensionInfo): Extension.Installed {
|
||||
return try {
|
||||
loadExtension(context, extensionInfo)
|
||||
} catch (e: Throwable) {
|
||||
val pkgInfo = extensionInfo.packageInfo
|
||||
logcat(LogPriority.ERROR, e) { "Extension load error: ${pkgInfo.packageName}" }
|
||||
Extension.NotLoaded(
|
||||
name = pkgInfo.packageName,
|
||||
pkgName = pkgInfo.packageName,
|
||||
versionName = pkgInfo.versionName.orEmpty(),
|
||||
versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo),
|
||||
isShared = extensionInfo.isShared,
|
||||
contentWarning = ContentWarning.SAFE,
|
||||
reason = Extension.NotLoaded.Reason.Failed(e.rootMessage, e.stackTraceToString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension
|
||||
*
|
||||
* @param context The application context.
|
||||
* @param extensionInfo The extension to load.
|
||||
*/
|
||||
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
|
||||
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): Extension.Installed {
|
||||
val trustExtension: TrustExtension = context.appGraph.trustExtension
|
||||
val sourcePreferences = context.appGraph.sourcePreferences
|
||||
val enabledContentWarnings = sourcePreferences.enabledContentWarnings.get()
|
||||
@@ -229,21 +251,54 @@ internal object ExtensionLoader {
|
||||
|
||||
val pkgManager = context.packageManager
|
||||
val pkgInfo = extensionInfo.packageInfo
|
||||
val appInfo = pkgInfo.applicationInfo!!
|
||||
val appInfo = pkgInfo.applicationInfo
|
||||
val metaData = appInfo?.metaData
|
||||
val pkgName = pkgInfo.packageName
|
||||
|
||||
val extName = appInfo.metaData.getString(METADATA_NAME)
|
||||
?: pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ")
|
||||
val extName = metaData?.getString(METADATA_NAME)
|
||||
?: appInfo?.let { pkgManager.getApplicationLabel(it).toString().substringAfter("Tachiyomi: ") }
|
||||
?: pkgName
|
||||
val versionName = pkgInfo.versionName
|
||||
val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo)
|
||||
val contentWarning = when {
|
||||
metaData == null -> ContentWarning.SAFE
|
||||
metaData.containsKey(METADATA_CONTENT_WARNING) -> {
|
||||
when (metaData.getInt(METADATA_CONTENT_WARNING)) {
|
||||
1 -> ContentWarning.MIXED
|
||||
2 -> ContentWarning.NSFW
|
||||
else -> ContentWarning.SAFE
|
||||
}
|
||||
}
|
||||
metaData.getInt(METADATA_NSFW) == 1 -> ContentWarning.NSFW
|
||||
else -> ContentWarning.SAFE
|
||||
}
|
||||
|
||||
fun notLoaded(
|
||||
reason: Extension.NotLoaded.Reason,
|
||||
libVersion: Double? = null,
|
||||
) = Extension.NotLoaded(
|
||||
name = extName,
|
||||
pkgName = pkgName,
|
||||
versionName = versionName.orEmpty(),
|
||||
versionCode = versionCode,
|
||||
isShared = extensionInfo.isShared,
|
||||
contentWarning = contentWarning,
|
||||
libVersion = libVersion,
|
||||
reason = reason,
|
||||
)
|
||||
|
||||
if (appInfo == null || metaData == null) {
|
||||
logcat(LogPriority.WARN) { "Missing application info for extension $extName" }
|
||||
return notLoaded(Extension.NotLoaded.Reason.Malformed)
|
||||
}
|
||||
|
||||
if (versionName.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Missing versionName for extension $extName" }
|
||||
return LoadResult.Error
|
||||
return notLoaded(Extension.NotLoaded.Reason.Malformed)
|
||||
}
|
||||
|
||||
// Validate lib version
|
||||
val libVersion = appInfo.metaData.getFloat(METADATA_EXTENSION_LIB)
|
||||
val libVersion = metaData.getFloat(METADATA_EXTENSION_LIB)
|
||||
.takeUnless { it == 0.0f }
|
||||
?.toString()
|
||||
?.toDouble()
|
||||
@@ -252,50 +307,37 @@ internal object ExtensionLoader {
|
||||
logcat(LogPriority.WARN) {
|
||||
"Lib version is $libVersion, while only version(s) ${SUPPORTED_LIB_VERSIONS.joinToString()} are supported"
|
||||
}
|
||||
return LoadResult.Error
|
||||
return notLoaded(Extension.NotLoaded.Reason.UnsupportedLibVersion, libVersion)
|
||||
}
|
||||
|
||||
val signatures = getSignatures(pkgInfo)
|
||||
if (signatures.isNullOrEmpty()) {
|
||||
logcat(LogPriority.WARN) { "Package $pkgName isn't signed" }
|
||||
return LoadResult.Error
|
||||
return notLoaded(Extension.NotLoaded.Reason.Unsigned, libVersion)
|
||||
} else if (!trustExtension.isTrusted(pkgInfo, signatures)) {
|
||||
val extension = Extension.Untrusted(
|
||||
extName,
|
||||
pkgName,
|
||||
versionName,
|
||||
versionCode,
|
||||
libVersion,
|
||||
signatures.last(),
|
||||
)
|
||||
logcat(LogPriority.WARN) { "Extension $pkgName isn't trusted" }
|
||||
return LoadResult.Untrusted(extension)
|
||||
return notLoaded(Extension.NotLoaded.Reason.Untrusted(signatures.last()), libVersion)
|
||||
}
|
||||
|
||||
val contentWarning = when {
|
||||
appInfo.metaData.containsKey(METADATA_CONTENT_WARNING) -> {
|
||||
when (appInfo.metaData.getInt(METADATA_CONTENT_WARNING)) {
|
||||
1 -> ContentWarning.MIXED
|
||||
2 -> ContentWarning.NSFW
|
||||
else -> ContentWarning.SAFE
|
||||
}
|
||||
}
|
||||
appInfo.metaData.getInt(METADATA_NSFW) == 1 -> ContentWarning.NSFW
|
||||
else -> ContentWarning.SAFE
|
||||
}
|
||||
if (applyContentWarningsToInstalled && contentWarning !in enabledContentWarnings) {
|
||||
logcat(LogPriority.WARN) { "Extension $pkgName with $contentWarning not allowed" }
|
||||
return LoadResult.Error
|
||||
return notLoaded(Extension.NotLoaded.Reason.Filtered, libVersion)
|
||||
}
|
||||
|
||||
val classLoader = try {
|
||||
DelegateLastClassLoaderCompat(appInfo.sourceDir, null, context.classLoader)
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($pkgName)" }
|
||||
return LoadResult.Error
|
||||
return notLoaded(Extension.NotLoaded.Reason.Failed(e.rootMessage, e.stackTraceToString()), libVersion)
|
||||
}
|
||||
|
||||
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
|
||||
val sourceClasses = metaData.getString(METADATA_SOURCE_CLASS)
|
||||
if (sourceClasses.isNullOrBlank()) {
|
||||
logcat(LogPriority.WARN) { "Missing source class for extension $extName" }
|
||||
return notLoaded(Extension.NotLoaded.Reason.Malformed, libVersion)
|
||||
}
|
||||
|
||||
val sources = sourceClasses
|
||||
.split(";")
|
||||
.map {
|
||||
val sourceClass = it.trim()
|
||||
@@ -314,7 +356,10 @@ internal object ExtensionLoader {
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e) { "Extension load error: $extName ($it)" }
|
||||
return LoadResult.Error
|
||||
return notLoaded(
|
||||
Extension.NotLoaded.Reason.Failed(e.rootMessage, e.stackTraceToString()),
|
||||
libVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +370,7 @@ internal object ExtensionLoader {
|
||||
else -> "all"
|
||||
}
|
||||
|
||||
val extension = Extension.Installed(
|
||||
return Extension.Loaded(
|
||||
name = extName,
|
||||
pkgName = pkgName,
|
||||
versionName = versionName,
|
||||
@@ -334,11 +379,10 @@ internal object ExtensionLoader {
|
||||
lang = lang,
|
||||
contentWarning = contentWarning,
|
||||
sources = sources,
|
||||
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
icon = appInfo.loadIcon(pkgManager),
|
||||
pkgFactory = metaData.getString(METADATA_SOURCE_FACTORY),
|
||||
icon = runCatching { appInfo.loadIcon(pkgManager) }.getOrNull(),
|
||||
isShared = extensionInfo.isShared,
|
||||
)
|
||||
return LoadResult.Success(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,11 +424,11 @@ internal object ExtensionLoader {
|
||||
*/
|
||||
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
|
||||
val signingInfo = pkgInfo.signingInfo
|
||||
when {
|
||||
signingInfo == null -> null
|
||||
signingInfo.hasMultipleSigners() -> signingInfo.apkContentsSigners
|
||||
else -> signingInfo.signingCertificateHistory
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -412,3 +456,12 @@ internal object ExtensionLoader {
|
||||
val isShared: Boolean,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The message of the deepest cause, which is the one that actually says what went wrong.
|
||||
*/
|
||||
private val Throwable.rootMessage: String
|
||||
get() {
|
||||
val root = generateSequence(this) { it.cause }.last()
|
||||
return listOfNotNull(root::class.simpleName, root.message).joinToString(": ")
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ class AndroidSourceManager(
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
extensionManager.installedExtensionsFlow
|
||||
extensionManager.loadedExtensionsFlow
|
||||
.collectLatest { extensions ->
|
||||
val mutableMap = ConcurrentHashMap<Long, Source>(
|
||||
mapOf(LocalSource.ID to localSource),
|
||||
|
||||
@@ -32,7 +32,7 @@ fun extensionsTab(
|
||||
val context = LocalContext.current
|
||||
|
||||
val updatesCount by extensionsViewModel.updatesCount.collectAsStateWithLifecycle()
|
||||
var privateExtensionToUninstall by remember { mutableStateOf<Extension?>(null) }
|
||||
var privateExtensionToUninstall by remember { mutableStateOf<Extension.Installed?>(null) }
|
||||
|
||||
return TabContent(
|
||||
titleRes = MR.strings.label_extensions,
|
||||
@@ -62,7 +62,7 @@ fun extensionsTab(
|
||||
onLongClickItem = { extension ->
|
||||
when (extension) {
|
||||
is Extension.Available -> extensionsViewModel.installExtension(extension)
|
||||
else -> {
|
||||
is Extension.Installed -> {
|
||||
if (context.isPackageInstalled(extension.pkgName)) {
|
||||
extensionsViewModel.uninstallExtension(extension)
|
||||
} else {
|
||||
|
||||
@@ -74,17 +74,21 @@ class ExtensionsViewModel(
|
||||
.map { searchQueryPredicate(it ?: "") },
|
||||
currentDownloads,
|
||||
getExtensions.subscribe(),
|
||||
) { predicate, downloads, (_updates, _installed, _available, _untrusted) ->
|
||||
) { predicate, downloads, (_updates, _loaded, _available, _notLoaded) ->
|
||||
buildMap {
|
||||
val updates = _updates.filter(predicate).map(extensionMapper(downloads))
|
||||
if (updates.isNotEmpty()) {
|
||||
put(ExtensionUiModel.Header.Resource(MR.strings.ext_updates_pending), updates)
|
||||
}
|
||||
|
||||
val installed = _installed.filter(predicate).map(extensionMapper(downloads))
|
||||
val untrusted = _untrusted.filter(predicate).map(extensionMapper(downloads))
|
||||
if (installed.isNotEmpty() || untrusted.isNotEmpty()) {
|
||||
put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), installed + untrusted)
|
||||
val notLoaded = _notLoaded.filter(predicate).map(extensionMapper(downloads))
|
||||
if (notLoaded.isNotEmpty()) {
|
||||
put(ExtensionUiModel.Header.Resource(MR.strings.ext_not_loaded), notLoaded)
|
||||
}
|
||||
|
||||
val loaded = _loaded.filter(predicate).map(extensionMapper(downloads))
|
||||
if (loaded.isNotEmpty()) {
|
||||
put(ExtensionUiModel.Header.Resource(MR.strings.ext_installed), loaded)
|
||||
}
|
||||
|
||||
val languagesWithExtensions = _available
|
||||
@@ -138,7 +142,7 @@ class ExtensionsViewModel(
|
||||
if (extension.name.contains(subquery, ignoreCase = true)) return@any true
|
||||
|
||||
when (extension) {
|
||||
is Extension.Installed -> extension.sources.any { source ->
|
||||
is Extension.Loaded -> extension.sources.any { source ->
|
||||
source.name.contains(subquery, ignoreCase = true) ||
|
||||
(source as? HttpSource)?.getHomeUrl()?.contains(subquery, ignoreCase = true) == true ||
|
||||
source.id == subquery.toLongOrNull()
|
||||
@@ -164,7 +168,7 @@ class ExtensionsViewModel(
|
||||
viewModelScope.launchIO {
|
||||
state.value.items.values.flatten()
|
||||
.map { it.extension }
|
||||
.filterIsInstance<Extension.Installed>()
|
||||
.filterIsInstance<Extension.Loaded>()
|
||||
.filter { it.hasUpdate }
|
||||
.forEach(::updateExtension)
|
||||
}
|
||||
@@ -176,7 +180,7 @@ class ExtensionsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateExtension(extension: Extension.Installed) {
|
||||
fun updateExtension(extension: Extension.Loaded) {
|
||||
viewModelScope.launchIO {
|
||||
extensionManager.updateExtension(extension).collectToInstallUpdate(extension)
|
||||
}
|
||||
@@ -202,7 +206,7 @@ class ExtensionsViewModel(
|
||||
.onCompletion { removeDownloadState(extension) }
|
||||
.collect()
|
||||
|
||||
fun uninstallExtension(extension: Extension) {
|
||||
fun uninstallExtension(extension: Extension.Installed) {
|
||||
extensionManager.uninstallExtension(extension)
|
||||
}
|
||||
|
||||
@@ -219,7 +223,7 @@ class ExtensionsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun trustExtension(extension: Extension.Untrusted) {
|
||||
fun trustExtension(extension: Extension.NotLoaded) {
|
||||
viewModelScope.launch {
|
||||
extensionManager.trust(extension)
|
||||
}
|
||||
|
||||
+3
-3
@@ -56,7 +56,7 @@ class ExtensionDetailsViewModel(
|
||||
fun create(pkgName: String): ExtensionDetailsViewModel
|
||||
}
|
||||
|
||||
val state: StateFlow<State> = extensionManager.installedExtensionsFlow
|
||||
val state: StateFlow<State> = extensionManager.loadedExtensionsFlow
|
||||
.map { it.firstOrNull { extension -> extension.pkgName == pkgName } }
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { extension ->
|
||||
@@ -73,7 +73,7 @@ class ExtensionDetailsViewModel(
|
||||
private val successState: State.Success?
|
||||
get() = state.value as? State.Success
|
||||
|
||||
private fun subscribeToSources(extension: Extension.Installed): Flow<List<ExtensionSourceItem>> {
|
||||
private fun subscribeToSources(extension: Extension.Loaded): Flow<List<ExtensionSourceItem>> {
|
||||
return getExtensionSources.subscribe(extension)
|
||||
.map {
|
||||
it.sortedWith(
|
||||
@@ -142,7 +142,7 @@ class ExtensionDetailsViewModel(
|
||||
|
||||
@Immutable
|
||||
data class Success(
|
||||
val extension: Extension.Installed,
|
||||
val extension: Extension.Loaded,
|
||||
val isIncognito: Boolean,
|
||||
val sources: List<ExtensionSourceItem>,
|
||||
) : State
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ abstract class SearchViewModel(
|
||||
return enabledSources
|
||||
}
|
||||
|
||||
return extensionManager.getInstalledExtensions()
|
||||
return extensionManager.getLoadedExtensions()
|
||||
.filter { it.pkgName == filter }
|
||||
.flatMap { it.sources }
|
||||
.filter { it in enabledSources }
|
||||
|
||||
@@ -6,6 +6,7 @@ import dev.zacsweers.metro.Inject
|
||||
import eu.kanade.domain.base.BasePreferences
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||
import eu.kanade.tachiyomi.extension.model.Extension
|
||||
import eu.kanade.tachiyomi.network.NetworkPreferences
|
||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.system.WebViewUtil
|
||||
@@ -65,7 +66,7 @@ class CrashLogUtil(
|
||||
private suspend fun getExtensionsInfo(): String? {
|
||||
val availableExtensions = extensionManager.availableExtensionsFlow.value.associateBy { it.pkgName }
|
||||
|
||||
val extensionInfoList = extensionManager.getInstalledExtensions()
|
||||
val outdatedInfoList = extensionManager.getLoadedExtensions()
|
||||
.sortedBy { it.name }
|
||||
.mapNotNull {
|
||||
val availableExtension = availableExtensions[it.pkgName]
|
||||
@@ -80,6 +81,24 @@ class CrashLogUtil(
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
val notLoadedInfoList = extensionManager.getNotLoadedExtensions()
|
||||
.sortedBy { it.name }
|
||||
.map { extension ->
|
||||
buildString {
|
||||
appendLine("- ${extension.name}")
|
||||
appendLine(" Installed: ${extension.versionName} (lib ${extension.libVersion ?: "?"})")
|
||||
append(" Not loaded: ${extension.reason.description}")
|
||||
|
||||
val reason = extension.reason
|
||||
if (reason is Extension.NotLoaded.Reason.Failed) {
|
||||
appendLine()
|
||||
append(reason.stackTrace.trimEnd().prependIndent(" "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val extensionInfoList = outdatedInfoList + notLoadedInfoList
|
||||
|
||||
return if (extensionInfoList.isNotEmpty()) {
|
||||
(listOf("Problematic extensions:") + extensionInfoList)
|
||||
.joinToString("\n")
|
||||
@@ -88,3 +107,13 @@ class CrashLogUtil(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val Extension.NotLoaded.Reason.description: String
|
||||
get() = when (this) {
|
||||
is Extension.NotLoaded.Reason.Untrusted -> "Untrusted"
|
||||
Extension.NotLoaded.Reason.Filtered -> "Filtered by content warning"
|
||||
Extension.NotLoaded.Reason.Unsigned -> "Unsigned"
|
||||
Extension.NotLoaded.Reason.UnsupportedLibVersion -> "Unsupported lib version"
|
||||
Extension.NotLoaded.Reason.Malformed -> "Malformed"
|
||||
is Extension.NotLoaded.Reason.Failed -> "Failed ($message)"
|
||||
}
|
||||
|
||||
@@ -6,33 +6,26 @@ import mihon.domain.extension.model.ContentWarning
|
||||
import mihon.domain.extension.model.ExtensionStore
|
||||
import tachiyomi.domain.source.model.StubSource
|
||||
|
||||
sealed class Extension {
|
||||
sealed interface Extension {
|
||||
|
||||
abstract val name: String
|
||||
abstract val pkgName: String
|
||||
abstract val versionName: String
|
||||
abstract val versionCode: Long
|
||||
abstract val libVersion: Double
|
||||
abstract val lang: String?
|
||||
abstract val contentWarning: ContentWarning
|
||||
val name: String
|
||||
val pkgName: String
|
||||
val versionName: String
|
||||
val versionCode: Long
|
||||
val libVersion: Double?
|
||||
val lang: String?
|
||||
val contentWarning: ContentWarning
|
||||
|
||||
data class Installed(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
override val lang: String,
|
||||
override val contentWarning: ContentWarning,
|
||||
val pkgFactory: String?,
|
||||
val sources: List<Source>,
|
||||
val icon: Drawable?,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val isShared: Boolean,
|
||||
val store: ExtensionStore? = null,
|
||||
) : Extension()
|
||||
/**
|
||||
* An extension whose apk is on the device, whether or not it ended up being loaded.
|
||||
*/
|
||||
sealed interface Installed : Extension {
|
||||
val isShared: Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* An extension that isn't on the device yet, as listed by an [ExtensionStore].
|
||||
*/
|
||||
data class Available(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
@@ -45,7 +38,7 @@ sealed class Extension {
|
||||
val apkUrl: String,
|
||||
val iconUrl: String,
|
||||
val store: ExtensionStore,
|
||||
) : Extension() {
|
||||
) : Extension {
|
||||
|
||||
data class Source(
|
||||
val id: Long,
|
||||
@@ -63,14 +56,60 @@ sealed class Extension {
|
||||
}
|
||||
}
|
||||
|
||||
data class Untrusted(
|
||||
/**
|
||||
* An installed extension whose sources are registered and usable.
|
||||
*/
|
||||
data class Loaded(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val libVersion: Double,
|
||||
val signatureHash: String,
|
||||
override val lang: String,
|
||||
override val contentWarning: ContentWarning,
|
||||
override val isShared: Boolean,
|
||||
val pkgFactory: String?,
|
||||
val sources: List<Source>,
|
||||
val icon: Drawable?,
|
||||
val hasUpdate: Boolean = false,
|
||||
val isObsolete: Boolean = false,
|
||||
val store: ExtensionStore? = null,
|
||||
) : Installed
|
||||
|
||||
/**
|
||||
* An installed extension that was never loaded, so it provides no sources. [lang] is derived
|
||||
* from the sources and [libVersion] from metadata, so neither is always known here.
|
||||
*/
|
||||
data class NotLoaded(
|
||||
override val name: String,
|
||||
override val pkgName: String,
|
||||
override val versionName: String,
|
||||
override val versionCode: Long,
|
||||
override val isShared: Boolean,
|
||||
override val contentWarning: ContentWarning,
|
||||
override val libVersion: Double? = null,
|
||||
override val lang: String? = null,
|
||||
override val contentWarning: ContentWarning = ContentWarning.SAFE,
|
||||
) : Extension()
|
||||
val reason: Reason,
|
||||
) : Installed {
|
||||
|
||||
sealed interface Reason {
|
||||
/** Signature isn't trusted yet. Resolvable by the user accepting it. */
|
||||
data class Untrusted(val signatureHash: String) : Reason
|
||||
|
||||
/** Its [contentWarning] isn't one the user chose to load. */
|
||||
data object Filtered : Reason
|
||||
|
||||
/** No signature to check against at all. */
|
||||
data object Unsigned : Reason
|
||||
|
||||
/** Built against an extension lib this app version can't run. */
|
||||
data object UnsupportedLibVersion : Reason
|
||||
|
||||
/** Required package metadata is missing. */
|
||||
data object Malformed : Reason
|
||||
|
||||
/** Threw while its classes or sources were being instantiated. */
|
||||
data class Failed(val message: String, val stackTrace: String) : Reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
<string name="pref_allowed_content_warnings">Allowed content warnings</string>
|
||||
<string name="pref_apply_content_warnings_to_installed">Also apply to installed extensions</string>
|
||||
<string name="pref_apply_content_warnings_to_installed_summary">When off, extensions you already installed keep loading and updating regardless of their warning</string>
|
||||
<string name="content_warnings_info">Requires app restart to take effect. Extensions that aren\'t loaded won\'t provide their sources anywhere in the app. This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing 18+ content within the app.</string>
|
||||
<string name="content_warnings_info">Requires app restart to take effect. Installed extensions that aren\'t loaded stay listed without their sources; ones you haven\'t installed are hidden. This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing 18+ content within the app.</string>
|
||||
|
||||
<string name="relative_time_today">Today</string>
|
||||
|
||||
@@ -340,6 +340,15 @@
|
||||
<string name="ext_installed">Installed</string>
|
||||
<string name="ext_trust">Trust</string>
|
||||
<string name="ext_untrusted">Untrusted</string>
|
||||
<string name="ext_filtered">Filtered</string>
|
||||
<string name="ext_not_loaded">Not loaded</string>
|
||||
<string name="ext_not_loaded_dialog">Couldn\'t load</string>
|
||||
<string name="ext_copy_stacktrace">Copy stack trace</string>
|
||||
<string name="ext_filtered_message">This extension\'s content warning isn\'t one you chose to load, so its sources aren\'t available.</string>
|
||||
<string name="ext_unsigned_message">This extension isn\'t signed, so there\'s no signature to check it against.</string>
|
||||
<string name="ext_unsupported_message">This extension was built for a version of the extension library this app version can\'t load. Updating the app or the extension may help.</string>
|
||||
<string name="ext_malformed_message">This extension is missing information the app needs to load it.</string>
|
||||
<string name="ext_load_failed_message">This extension threw an error while loading, so none of its sources are available.</string>
|
||||
<string name="ext_uninstall">Uninstall</string>
|
||||
<string name="ext_remove">Remove</string>
|
||||
<string name="ext_confirm_remove">Remove Extension?</string>
|
||||
|
||||
Reference in New Issue
Block a user