Expose installed extensions that failed to load (#3953)

This commit is contained in:
AntsyLich
2026-09-14 21:34:55 +06:00
committed by GitHub
parent 0e0bc6972a
commit e6d8efe6f8
19 changed files with 455 additions and 239 deletions
@@ -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) {
ExtensionTrustDialog(
onClickConfirm = {
onTrustExtension(trustState!!)
trustState = null
},
onClickDismiss = {
onUninstallExtension(trustState!!)
trustState = null
},
onDismissRequest = {
trustState = null
},
)
notLoadedState?.let { extension ->
val dismiss = { notLoadedState = null }
if (extension.reason is Extension.NotLoaded.Reason.Untrusted) {
ExtensionTrustDialog(
onClickConfirm = {
onTrustExtension(extension)
dismiss()
},
onClickDismiss = {
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)
}
@@ -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
@@ -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>