Replace extension NSFW flag with content warning app-wide (#3951)

This commit is contained in:
AntsyLich
2026-09-14 21:00:06 +06:00
committed by GitHub
parent cbafe9bf3f
commit 0fbaa1ecec
14 changed files with 202 additions and 68 deletions
@@ -15,7 +15,7 @@ class GetExtensionsByType(
) {
fun subscribe(): Flow<Extensions> {
val showNsfwSources = preferences.showNsfwSource.get()
val enabledContentWarnings = preferences.enabledContentWarnings.get()
return combine(
preferences.enabledLanguages.changes(),
@@ -24,7 +24,6 @@ class GetExtensionsByType(
extensionManager.availableExtensionsFlow,
) { enabledLanguages, _installed, _untrusted, _available ->
val (updates, installed) = _installed
.filter { (showNsfwSources || !it.isNsfw) }
.sortedWith(
compareBy<Extension.Installed> { !it.isObsolete }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
@@ -38,7 +37,7 @@ class GetExtensionsByType(
.filter { extension ->
_installed.none { it.pkgName == extension.pkgName } &&
_untrusted.none { it.pkgName == extension.pkgName } &&
(showNsfwSources || !extension.isNsfw)
extension.contentWarning in enabledContentWarnings
}
.flatMap { ext ->
ext.sources.filter { it.lang in enabledLanguages }
@@ -5,10 +5,12 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.tachiyomi.util.system.LocaleHelper
import mihon.domain.extension.model.ContentWarning
import mihon.domain.migration.models.MigrationFlag
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
import tachiyomi.core.common.preference.getEnumSet
import tachiyomi.core.common.preference.getLongArray
import tachiyomi.domain.library.model.LibraryDisplayMode
@@ -41,7 +43,10 @@ class SourcePreferences(
-1,
)
val showNsfwSource: Preference<Boolean> = preferenceStore.getBoolean("show_nsfw_source", true)
val enabledContentWarnings: Preference<Set<ContentWarning>> = preferenceStore.getEnumSet(
"enabled_content_warnings",
setOf(ContentWarning.SAFE, ContentWarning.MIXED, ContentWarning.NSFW),
)
val migrationSortingMode: Preference<SetMigrateSorting.Mode> = preferenceStore.getEnum(
"pref_migration_sorting",
@@ -32,16 +32,18 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.extension.interactor.ExtensionSourceItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.browse.components.label
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.WarningBanner
@@ -152,7 +154,8 @@ private fun ExtensionDetails(
onClickIncognito: (Boolean) -> Unit,
) {
val context = LocalContext.current
var showNsfwWarning by remember { mutableStateOf(false) }
var showContentWarning by remember { mutableStateOf(false) }
val contentWarning = extension.contentWarning.label
ScrollbarLazyColumn(
contentPadding = contentPadding,
@@ -175,8 +178,8 @@ private fun ExtensionDetails(
}
Unit
}.takeIf { extension.isShared },
onClickAgeRating = {
showNsfwWarning = true
onClickContentWarning = {
showContentWarning = true
},
onExtIncognitoChange = onClickIncognito,
)
@@ -194,10 +197,12 @@ private fun ExtensionDetails(
)
}
}
if (showNsfwWarning) {
NsfwWarningDialog(
if (showContentWarning && contentWarning != null) {
ContentWarningDialog(
label = contentWarning.title,
description = contentWarning.description,
onClickConfirm = {
showNsfwWarning = false
showContentWarning = false
},
)
}
@@ -207,12 +212,13 @@ private fun ExtensionDetails(
private fun DetailsHeader(
extension: Extension,
extIncognitoMode: Boolean,
onClickAgeRating: () -> Unit,
onClickContentWarning: () -> Unit,
onClickUninstall: () -> Unit,
onClickAppInfo: (() -> Unit)?,
onExtIncognitoChange: (Boolean) -> Unit,
) {
val context = LocalContext.current
val contentWarning = extension.contentWarning.label
Column {
Column(
@@ -229,7 +235,7 @@ private fun DetailsHeader(
"""
Extension name: ${extension.name} (lang: ${extension.lang}; package: ${extension.pkgName})
Extension version: ${extension.versionName} (lib: ${extension.libVersion}; version code: ${extension.versionCode})
NSFW: ${extension.isNsfw}
Content warning: ${extension.contentWarning}
""".trimIndent(),
)
@@ -277,7 +283,7 @@ private fun DetailsHeader(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = MaterialTheme.padding.extraLarge,
horizontal = MaterialTheme.padding.medium,
vertical = MaterialTheme.padding.small,
),
horizontalArrangement = Arrangement.SpaceEvenly,
@@ -292,23 +298,20 @@ private fun DetailsHeader(
InfoDivider()
InfoText(
modifier = Modifier.weight(if (extension.isNsfw) 1.5f else 1f),
modifier = Modifier.weight(1f),
primaryText = LocaleHelper.getSourceDisplayName(extension.lang, context),
secondaryText = stringResource(MR.strings.ext_info_language),
)
if (extension.isNsfw) {
if (contentWarning != null) {
InfoDivider()
InfoText(
modifier = Modifier.weight(1f),
primaryText = stringResource(MR.strings.ext_nsfw_short),
primaryTextStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.error,
fontWeight = FontWeight.Medium,
),
secondaryText = stringResource(MR.strings.ext_info_age_rating),
onClick = onClickAgeRating,
primaryText = stringResource(contentWarning.title),
primaryTextColor = contentWarning.color,
secondaryText = stringResource(MR.strings.ext_info_warning),
onClick = onClickContentWarning,
)
}
}
@@ -366,7 +369,7 @@ private fun InfoText(
primaryText: String,
secondaryText: String,
modifier: Modifier = Modifier,
primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge,
primaryTextColor: Color = Color.Unspecified,
onClick: (() -> Unit)? = null,
) {
val clickableModifier = if (onClick != null) {
@@ -383,13 +386,16 @@ private fun InfoText(
Text(
text = primaryText,
textAlign = TextAlign.Center,
style = primaryTextStyle,
style = MaterialTheme.typography.titleSmall,
color = primaryTextColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = secondaryText + if (onClick != null) "" else "",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
)
}
@@ -398,7 +404,9 @@ private fun InfoText(
@Composable
private fun InfoDivider() {
VerticalDivider(
modifier = Modifier.height(20.dp),
modifier = Modifier
.padding(horizontal = MaterialTheme.padding.small)
.height(24.dp),
)
}
@@ -444,12 +452,20 @@ private fun SourceSwitchPreference(
}
@Composable
private fun NsfwWarningDialog(
private fun ContentWarningDialog(
label: StringResource,
description: StringResource,
onClickConfirm: () -> Unit,
) {
AlertDialog(
title = {
Text(
text = stringResource(label),
style = MaterialTheme.typography.titleMedium,
)
},
text = {
Text(text = stringResource(MR.strings.ext_nsfw_warning))
Text(text = stringResource(description))
},
confirmButton = {
TextButton(onClick = onClickConfirm) {
@@ -38,6 +38,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import dev.icerock.moko.resources.StringResource
import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.browse.components.label
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
@@ -367,22 +368,27 @@ private fun ExtensionItemContent(
)
}
val warning = when {
extension is Extension.Untrusted -> MR.strings.ext_untrusted
extension is Extension.Installed && extension.isObsolete -> MR.strings.ext_obsolete
extension.isNsfw -> MR.strings.ext_nsfw_short
else -> null
}
if (warning != null) {
val warnings = listOfNotNull(
when {
extension is Extension.Untrusted ->
MR.strings.ext_untrusted to MaterialTheme.colorScheme.error
extension is Extension.Installed && extension.isObsolete ->
MR.strings.ext_obsolete to MaterialTheme.colorScheme.error
else -> null
},
extension.contentWarning.label?.let { it.title to it.color },
)
warnings.forEach { (label, color) ->
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
hasAlreadyShownAnElement = true
Text(
text = stringResource(warning).uppercase(),
color = MaterialTheme.colorScheme.error,
text = stringResource(label).uppercase(),
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (extension is Extension.Installed && !extension.isShared) {
if (hasAlreadyShownAnElement) DotSeparatorNoSpaceText()
Text(
@@ -0,0 +1,32 @@
package eu.kanade.presentation.browse.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.StringResource
import mihon.domain.extension.model.ContentWarning
import tachiyomi.i18n.MR
internal data class ContentWarningLabel(
val title: StringResource,
val description: StringResource,
val color: Color,
)
internal val ContentWarning.label: ContentWarningLabel?
@Composable
@ReadOnlyComposable
get() = when (this) {
ContentWarning.SAFE -> null
ContentWarning.MIXED -> ContentWarningLabel(
title = MR.strings.ext_content_warning_mixed,
description = MR.strings.ext_content_warning_mixed_description,
color = MaterialTheme.colorScheme.primary,
)
ContentWarning.NSFW -> ContentWarningLabel(
title = MR.strings.ext_content_warning_nsfw,
description = MR.strings.ext_content_warning_nsfw_description,
color = MaterialTheme.colorScheme.error,
)
}
@@ -13,6 +13,7 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import mihon.app.di.appGraph
import mihon.domain.extension.model.ContentWarning
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
@@ -52,19 +53,35 @@ object SettingsBrowseScreen : SearchableSettings {
),
),
Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_nsfw_content),
title = stringResource(MR.strings.pref_category_extensions),
preferenceItems = listOf(
Preference.PreferenceItem.SwitchPreference(
preference = sourcePreferences.showNsfwSource,
title = stringResource(MR.strings.pref_show_nsfw_source),
subtitle = stringResource(MR.strings.requires_app_restart),
onValueChanged = {
(context as FragmentActivity).authenticate(
title = context.stringResource(MR.strings.pref_category_nsfw_content),
)
Preference.PreferenceItem.MultiSelectListPreference(
preference = sourcePreferences.enabledContentWarnings,
entries = mapOf(
ContentWarning.SAFE to stringResource(MR.strings.ext_content_warning_safe),
ContentWarning.MIXED to stringResource(MR.strings.ext_content_warning_mixed),
ContentWarning.NSFW to stringResource(MR.strings.ext_content_warning_nsfw),
),
title = stringResource(MR.strings.pref_allowed_content_warnings),
subtitleProvider = { value, entries ->
remember(value, entries) {
entries.filterKeys { it in value }.values.joinToString()
}
.takeUnless { it.isBlank() }
?: stringResource(MR.strings.none)
},
onValueChanged = { newValue ->
val added = newValue - sourcePreferences.enabledContentWarnings.get()
if (added.any { it != ContentWarning.SAFE }) {
(context as FragmentActivity).authenticate(
title = context.stringResource(MR.strings.pref_allowed_content_warnings),
)
} else {
true
}
},
),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.parental_controls_info)),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.content_warnings_info)),
),
),
)
@@ -20,6 +20,7 @@ import kotlinx.coroutines.runBlocking
import logcat.LogPriority
import mihon.app.di.appGraph
import mihon.data.dalvik.DelegateLastClassLoaderCompat
import mihon.domain.extension.model.ContentWarning
import tachiyomi.core.common.util.system.logcat
import java.io.File
@@ -222,7 +223,7 @@ internal object ExtensionLoader {
*/
private suspend fun loadExtension(context: Context, extensionInfo: ExtensionInfo): LoadResult {
val trustExtension: TrustExtension = context.appGraph.trustExtension
val loadNsfwSource: Boolean = context.appGraph.sourcePreferences.showNsfwSource.get()
val enabledContentWarnings = context.appGraph.sourcePreferences.enabledContentWarnings.get()
val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo
@@ -269,10 +270,19 @@ internal object ExtensionLoader {
return LoadResult.Untrusted(extension)
}
val isNsfw = appInfo.metaData.getInt(METADATA_CONTENT_WARNING) > 0 ||
appInfo.metaData.getInt(METADATA_NSFW) == 1
if (!loadNsfwSource && isNsfw) {
logcat(LogPriority.WARN) { "NSFW extension $pkgName not allowed" }
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 (contentWarning !in enabledContentWarnings) {
logcat(LogPriority.WARN) { "Extension $pkgName with $contentWarning not allowed" }
return LoadResult.Error
}
@@ -320,7 +330,7 @@ internal object ExtensionLoader {
versionCode = versionCode,
libVersion = libVersion,
lang = lang,
isNsfw = isNsfw,
contentWarning = contentWarning,
sources = sources,
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
icon = appInfo.loadIcon(pkgManager),