diff --git a/app/src/main/baselineProfiles/baseline-prof.txt b/app/src/main/baselineProfiles/baseline-prof.txt index 065092c4917..c21cf32f7d6 100644 --- a/app/src/main/baselineProfiles/baseline-prof.txt +++ b/app/src/main/baselineProfiles/baseline-prof.txt @@ -21730,7 +21730,7 @@ HSPLeu/kanade/domain/source/service/SourcePreferences;->getLastUsedSource()Ltach HSPLeu/kanade/domain/source/service/SourcePreferences;->getMigrationSortingDirection()Ltachiyomi/core/common/preference/Preference; HSPLeu/kanade/domain/source/service/SourcePreferences;->getMigrationSortingMode()Ltachiyomi/core/common/preference/Preference; HSPLeu/kanade/domain/source/service/SourcePreferences;->getPinnedSources()Ltachiyomi/core/common/preference/Preference; -HSPLeu/kanade/domain/source/service/SourcePreferences;->getShowNsfwSource()Ltachiyomi/core/common/preference/Preference; +HSPLeu/kanade/domain/source/service/SourcePreferences;->getContentWarningLevel()Ltachiyomi/core/common/preference/Preference; Leu/kanade/domain/source/service/SourcePreferences$$ExternalSyntheticLambda0; HSPLeu/kanade/domain/source/service/SourcePreferences$$ExternalSyntheticLambda0;->()V Leu/kanade/domain/source/service/SourcePreferences$$ExternalSyntheticLambda1; diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 729c86edd3f..7eec7ab58c1 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -182,7 +182,7 @@ class DomainModule : InjektModule { addSingletonFactory { SourceRepositoryImpl(get(), get()) } addSingletonFactory { StubSourceRepositoryImpl(get()) } - addFactory { GetEnabledSources(get(), get()) } + addFactory { GetEnabledSources(get(), get(), get()) } addFactory { GetLanguagesWithSources(get(), get()) } addFactory { GetRemoteManga(get()) } addFactory { GetSourcesWithFavoriteCount(get(), get()) } diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt index ce689a7f413..c3962fa03c0 100644 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt +++ b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionsByType.kt @@ -13,7 +13,7 @@ class GetExtensionsByType( ) { fun subscribe(): Flow { - val showNsfwSources = preferences.showNsfwSource.get() + val contentWarningLevel = preferences.contentWarningLevel.get() return combine( preferences.enabledLanguages.changes(), @@ -21,8 +21,10 @@ class GetExtensionsByType( extensionManager.untrustedExtensionsFlow, extensionManager.availableExtensionsFlow, ) { enabledLanguages, _installed, _untrusted, _available -> + // Installed extensions stay listed regardless of the content warning level, except + // under the strictest (Safe only) level — see ContentWarningLevel.allowsInstalled. val (updates, installed) = _installed - .filter { (showNsfwSources || !it.isNsfw) } + .filter { contentWarningLevel.allowsInstalled(it.contentWarning) } .sortedWith( compareBy { !it.isObsolete } .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, @@ -36,7 +38,7 @@ class GetExtensionsByType( .filter { extension -> _installed.none { it.pkgName == extension.pkgName } && _untrusted.none { it.pkgName == extension.pkgName } && - (showNsfwSources || !extension.isNsfw) + contentWarningLevel.allowsDiscovery(extension.contentWarning) } .flatMap { ext -> ext.sources.filter { it.lang in enabledLanguages } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt index 104897c623b..b8a67ba1a74 100644 --- a/app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt +++ b/app/src/main/java/eu/kanade/domain/source/interactor/GetEnabledSources.kt @@ -1,6 +1,7 @@ package eu.kanade.domain.source.interactor import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.extension.ExtensionManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -13,19 +14,36 @@ import tachiyomi.source.local.isLocal class GetEnabledSources( private val repository: SourceRepository, private val preferences: SourcePreferences, + private val extensionManager: ExtensionManager, ) { fun subscribe(): Flow> { + val sourcesWithContentWarnings = combine( + repository.getSources(), + extensionManager.installedExtensionsFlow, + ) { sources, installedExtensions -> + val contentWarningBySourceId = installedExtensions + .flatMap { extension -> extension.sources.map { it.id to extension.contentWarning } } + .toMap() + sources to contentWarningBySourceId + } + return combine( preferences.pinnedSources.changes(), preferences.enabledLanguages.changes(), preferences.disabledSources.changes(), preferences.lastUsedSource.changes(), - repository.getSources(), - ) { pinnedSourceIds, enabledLanguages, disabledSources, lastUsedSource, sources -> + sourcesWithContentWarnings, + ) { pinnedSourceIds, enabledLanguages, disabledSources, lastUsedSource, (sources, contentWarningBySourceId) -> + val contentWarningLevel = preferences.contentWarningLevel.get() + sources .filter { it.lang in enabledLanguages || it.isLocal() } .filterNot { it.id.toString() in disabledSources } + .filter { source -> + val contentWarning = contentWarningBySourceId[source.id] ?: return@filter true + contentWarningLevel.allowsInstalled(contentWarning) + } .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) .flatMap { val flag = if ("${it.id}" in pinnedSourceIds) Pins.pinned else Pins.unpinned diff --git a/app/src/main/java/eu/kanade/domain/source/model/ContentWarningLevel.kt b/app/src/main/java/eu/kanade/domain/source/model/ContentWarningLevel.kt new file mode 100644 index 00000000000..2a12bbe5727 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/model/ContentWarningLevel.kt @@ -0,0 +1,40 @@ +package eu.kanade.domain.source.model + +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.extension.model.ContentWarning +import tachiyomi.i18n.MR + +/** + * User-selectable cutoff for which [ContentWarning] tiers are surfaced when discovering new + * extensions/sources in Browse, and for which already-installed ones stay listed — see + * `GetExtensionsByType`. + * + * Entries are declared least to most permissive; [allowsDiscovery] treats that ordering as + * cumulative. + */ +enum class ContentWarningLevel(val titleRes: StringResource) { + SAFE(MR.strings.content_warning_level_safe), + SAFE_AND_MIXED(MR.strings.content_warning_level_safe_and_mixed), + ALL(MR.strings.content_warning_level_all), + ; + + /** Whether a not-yet-installed extension/source of this [contentWarning] shows up in Browse. */ + fun allowsDiscovery(contentWarning: ContentWarning): Boolean { + return when (this) { + SAFE -> !contentWarning.hasAdultContent + SAFE_AND_MIXED -> contentWarning != ContentWarning.NSFW + ALL -> true + } + } + + /** + * Whether an already-installed extension of this [contentWarning] stays listed. + * + * Only the strictest level, [SAFE], also hides installed extensions — [SAFE_AND_MIXED] and + * [ALL] never hide something the user explicitly installed, so they keep receiving updates + * and don't regress the fix for https://github.com/mihonapp/mihon/issues/1673. + */ + fun allowsInstalled(contentWarning: ContentWarning): Boolean { + return this != SAFE || !contentWarning.hasAdultContent + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index d861014ea0a..52d72263f12 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -1,6 +1,7 @@ package eu.kanade.domain.source.service import eu.kanade.domain.source.interactor.SetMigrateSorting +import eu.kanade.domain.source.model.ContentWarningLevel import eu.kanade.tachiyomi.util.system.LocaleHelper import mihon.domain.migration.models.MigrationFlag import tachiyomi.core.common.preference.Preference @@ -36,7 +37,10 @@ class SourcePreferences( -1, ) - val showNsfwSource: Preference = preferenceStore.getBoolean("show_nsfw_source", true) + val contentWarningLevel: Preference = preferenceStore.getEnum( + "content_warning_level", + ContentWarningLevel.SAFE_AND_MIXED, + ) val migrationSortingMode: Preference = preferenceStore.getEnum( "pref_migration_sorting", diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 2522c6c8774..a481ab7ad45 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -51,6 +51,7 @@ import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget import eu.kanade.presentation.more.settings.widget.TrailingWidgetBuffer import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.extension.model.ContentWarning import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsViewModel @@ -152,7 +153,7 @@ private fun ExtensionDetails( onClickIncognito: (Boolean) -> Unit, ) { val context = LocalContext.current - var showNsfwWarning by remember { mutableStateOf(false) } + var showContentWarning by remember { mutableStateOf(false) } ScrollbarLazyColumn( contentPadding = contentPadding, @@ -176,7 +177,7 @@ private fun ExtensionDetails( Unit }.takeIf { extension.isShared }, onClickAgeRating = { - showNsfwWarning = true + showContentWarning = true }, onExtIncognitoChange = onClickIncognito, ) @@ -194,10 +195,11 @@ private fun ExtensionDetails( ) } } - if (showNsfwWarning) { - NsfwWarningDialog( + if (showContentWarning) { + ContentWarningDialog( + contentWarning = extension.contentWarning, onClickConfirm = { - showNsfwWarning = false + showContentWarning = false }, ) } @@ -229,7 +231,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(), ) @@ -292,17 +294,23 @@ private fun DetailsHeader( InfoDivider() InfoText( - modifier = Modifier.weight(if (extension.isNsfw) 1.5f else 1f), + modifier = Modifier.weight(if (extension.contentWarning.hasAdultContent) 1.5f else 1f), primaryText = LocaleHelper.getSourceDisplayName(extension.lang, context), secondaryText = stringResource(MR.strings.ext_info_language), ) - if (extension.isNsfw) { + if (extension.contentWarning.hasAdultContent) { InfoDivider() InfoText( modifier = Modifier.weight(1f), - primaryText = stringResource(MR.strings.ext_nsfw_short), + primaryText = stringResource( + if (extension.contentWarning == ContentWarning.MIXED) { + MR.strings.ext_mixed_short + } else { + MR.strings.ext_nsfw_short + }, + ), primaryTextStyle = MaterialTheme.typography.bodyLarge.copy( color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Medium, @@ -444,12 +452,21 @@ private fun SourceSwitchPreference( } @Composable -private fun NsfwWarningDialog( +private fun ContentWarningDialog( + contentWarning: ContentWarning, onClickConfirm: () -> Unit, ) { AlertDialog( text = { - Text(text = stringResource(MR.strings.ext_nsfw_warning)) + Text( + text = stringResource( + if (contentWarning == ContentWarning.MIXED) { + MR.strings.ext_mixed_warning + } else { + MR.strings.ext_nsfw_warning + }, + ), + ) }, confirmButton = { TextButton(onClick = onClickConfirm) { diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt index 914cb5e7922..61a54bcd9b3 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionsScreen.kt @@ -49,6 +49,7 @@ import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState +import eu.kanade.tachiyomi.extension.model.ContentWarning import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.ui.browse.extension.ExtensionUiModel @@ -370,7 +371,8 @@ 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 + extension.contentWarning == ContentWarning.NSFW -> MR.strings.ext_nsfw_short + extension.contentWarning == ContentWarning.MIXED -> MR.strings.ext_mixed_short else -> null } if (warning != null) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt index 0c401d855ab..7382bd54598 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt @@ -9,10 +9,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.domain.source.model.ContentWarningLevel import eu.kanade.domain.source.service.SourcePreferences 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 eu.kanade.tachiyomi.util.system.toast import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow import tachiyomi.core.common.i18n.stringResource import tachiyomi.i18n.MR @@ -57,14 +59,17 @@ object SettingsBrowseScreen : SearchableSettings { Preference.PreferenceGroup( title = stringResource(MR.strings.pref_category_nsfw_content), preferenceItems = listOf( - Preference.PreferenceItem.SwitchPreference( - preference = sourcePreferences.showNsfwSource, + Preference.PreferenceItem.ListPreference( + preference = sourcePreferences.contentWarningLevel, + entries = ContentWarningLevel.entries + .associateWith { stringResource(it.titleRes) }, title = stringResource(MR.strings.pref_show_nsfw_source), - subtitle = stringResource(MR.strings.requires_app_restart), onValueChanged = { - (context as FragmentActivity).authenticate( + val authenticated = (context as FragmentActivity).authenticate( title = context.stringResource(MR.strings.pref_category_nsfw_content), ) + if (authenticated) context.toast(MR.strings.requires_app_restart) + authenticated }, ), Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.parental_controls_info)), diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt index 962fafa5bec..f7dcefab20d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionLoader.kt @@ -7,7 +7,7 @@ import android.content.pm.PackageManager import android.os.Build import androidx.core.content.pm.PackageInfoCompat import eu.kanade.domain.extension.interactor.TrustExtension -import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.extension.model.ContentWarning import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.source.Source @@ -39,11 +39,7 @@ import java.io.File */ internal object ExtensionLoader { - private val preferences: SourcePreferences by injectLazy() private val trustExtension: TrustExtension by injectLazy() - private val loadNsfwSource by lazy { - preferences.showNsfwSource.get() - } private const val EXTENSION_FEATURE = "tachiyomi.extension" private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" @@ -272,11 +268,22 @@ 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" } - return LoadResult.Error + // Adult extensions are always loaded here because this only runs for packages already + // installed on the device. The NSFW preference only gates discovery of new extensions + // (see GetExtensionsByType), so installed ones stay usable and keep receiving updates. + // + // `tachiyomix.contentWarning` keeps the numbering it was introduced with; the + // `CONTENT_WARNING_UNSPECIFIED` value later added to the store index format did not shift + // it. Absent metadata reads as -1 so the legacy flag still decides in that case. + val contentWarning = if (appInfo.metaData.getInt(METADATA_NSFW) == 1) { + ContentWarning.NSFW + } else { + when (appInfo.metaData.getInt(METADATA_CONTENT_WARNING, -1)) { + 0 -> ContentWarning.SAFE + 1 -> ContentWarning.MIXED + 2 -> ContentWarning.NSFW + else -> ContentWarning.UNSPECIFIED + } } val classLoader = try { @@ -323,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), diff --git a/app/src/main/java/mihon/core/migration/migrations/ContentWarningLevelMigration.kt b/app/src/main/java/mihon/core/migration/migrations/ContentWarningLevelMigration.kt new file mode 100644 index 00000000000..0f08f472178 --- /dev/null +++ b/app/src/main/java/mihon/core/migration/migrations/ContentWarningLevelMigration.kt @@ -0,0 +1,34 @@ +package mihon.core.migration.migrations + +import eu.kanade.domain.source.model.ContentWarningLevel +import eu.kanade.domain.source.service.SourcePreferences +import mihon.core.migration.Migration +import mihon.core.migration.MigrationContext +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.util.lang.withIOContext + +/** + * The boolean `show_nsfw_source` preference was replaced by the tri-state + * [SourcePreferences.contentWarningLevel]. Map each existing user's setting to the level that + * reproduces their prior behavior exactly: the old toggle only ever gated discovery of NSFW + * extensions, with Mixed always shown alongside Safe, so `false` becomes [ContentWarningLevel.SAFE_AND_MIXED] + * and `true` becomes [ContentWarningLevel.ALL]. + */ +class ContentWarningLevelMigration : Migration { + override val version: Float = 26f + + override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { + val preferenceStore = migrationContext.get() ?: return@withIOContext false + val sourcePreferences = migrationContext.get() ?: return@withIOContext false + + val oldShowNsfwSource = preferenceStore.getBoolean("show_nsfw_source", true) + if (oldShowNsfwSource.isSet()) { + sourcePreferences.contentWarningLevel.set( + if (oldShowNsfwSource.get()) ContentWarningLevel.ALL else ContentWarningLevel.SAFE_AND_MIXED, + ) + oldShowNsfwSource.delete() + } + + return@withIOContext true + } +} diff --git a/app/src/main/java/mihon/core/migration/migrations/Migrations.kt b/app/src/main/java/mihon/core/migration/migrations/Migrations.kt index 523b8e846d8..06f3177534c 100644 --- a/app/src/main/java/mihon/core/migration/migrations/Migrations.kt +++ b/app/src/main/java/mihon/core/migration/migrations/Migrations.kt @@ -10,4 +10,5 @@ val migrations: List CategoryPreferencesCleanupMigration(), InstallationIdMigration(), VerticalNavigatorMigration(), + ContentWarningLevelMigration(), ) diff --git a/data/src/main/java/mihon/data/extension/model/NetworkExtensionStore.kt b/data/src/main/java/mihon/data/extension/model/NetworkExtensionStore.kt index 756dc5c4835..8dfd53a5f7e 100644 --- a/data/src/main/java/mihon/data/extension/model/NetworkExtensionStore.kt +++ b/data/src/main/java/mihon/data/extension/model/NetworkExtensionStore.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.protobuf.ProtoNumber import mihon.data.extension.model.NetworkExtensionStore.ContentWarning import mihon.data.extension.model.NetworkExtensionStore.ExtensionList import mihon.domain.extension.model.ExtensionStore +import eu.kanade.tachiyomi.extension.model.ContentWarning as TachiyomiContentWarning import eu.kanade.tachiyomi.extension.model.Extension as TachiyomiExtension @SuppressLint("UnsafeOptInUsageError") @@ -57,7 +58,6 @@ data class NetworkExtensionStore( @ProtoNumber(7) val message: String? = null, ) - @Suppress("Unused") enum class ContentWarning { @ProtoNumber(0) @JsonNames("CONTENT_WARNING_UNSPECIFIED") @@ -104,7 +104,7 @@ fun ExtensionList.toAvailableExtensions(store: ExtensionStore): List= ContentWarning.MIXED, + contentWarning = extension.contentWarning.toDomain(), sources = extension.sources.map { source -> TachiyomiExtension.Available.Source( id = source.id, @@ -117,3 +117,10 @@ fun ExtensionList.toAvailableExtensions(store: ExtensionStore): List TachiyomiContentWarning.UNSPECIFIED + ContentWarning.SAFE -> TachiyomiContentWarning.SAFE + ContentWarning.MIXED -> TachiyomiContentWarning.MIXED + ContentWarning.NSFW -> TachiyomiContentWarning.NSFW +} diff --git a/data/src/main/java/mihon/data/extension/model/NetworkLegacyExtension.kt b/data/src/main/java/mihon/data/extension/model/NetworkLegacyExtension.kt index 9a47907ced1..04b10c4e97b 100644 --- a/data/src/main/java/mihon/data/extension/model/NetworkLegacyExtension.kt +++ b/data/src/main/java/mihon/data/extension/model/NetworkLegacyExtension.kt @@ -1,6 +1,7 @@ package mihon.data.extension.model import android.annotation.SuppressLint +import eu.kanade.tachiyomi.extension.model.ContentWarning import eu.kanade.tachiyomi.extension.model.Extension import kotlinx.serialization.Serializable import mihon.domain.extension.model.ExtensionStore @@ -35,7 +36,9 @@ data class NetworkLegacyExtension( versionCode = code, versionName = version, lang = lang, - isNsfw = nsfw == 1, + // The legacy index format has no notion of the three content tiers, so an unset flag + // only tells us the extension isn't dedicated to adult content, not that it's SAFE. + contentWarning = if (nsfw == 1) ContentWarning.NSFW else ContentWarning.UNSPECIFIED, sources = if (sources.isNullOrEmpty()) { listOf( Extension.Available.Source( diff --git a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/ContentWarning.kt b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/ContentWarning.kt new file mode 100644 index 00000000000..abd84798e32 --- /dev/null +++ b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/ContentWarning.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.extension.model + +/** + * Content classification of an extension as a whole, as declared by the extension itself or by the + * store it was listed in. + * + * Declared in order of increasing severity. + */ +enum class ContentWarning { + /** + * No classification was provided. Extensions from legacy stores, which only declare a boolean + * NSFW flag, land here when that flag is unset. + */ + UNSPECIFIED, + + /** Explicitly declared to serve no adult content. */ + SAFE, + + /** Mostly safe, but some of the content it serves is adult. */ + MIXED, + + /** Dedicated to adult content. */ + NSFW, + ; + + /** Whether sources from this extension may serve adult content at all. */ + val hasAdultContent: Boolean + get() = this == MIXED || this == NSFW +} diff --git a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index 2d6d2041e3f..727632f8f22 100644 --- a/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/domain/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -13,7 +13,7 @@ sealed class Extension { abstract val versionCode: Long abstract val libVersion: Double abstract val lang: String? - abstract val isNsfw: Boolean + abstract val contentWarning: ContentWarning data class Installed( override val name: String, @@ -22,7 +22,7 @@ sealed class Extension { override val versionCode: Long, override val libVersion: Double, override val lang: String, - override val isNsfw: Boolean, + override val contentWarning: ContentWarning, val pkgFactory: String?, val sources: List, val icon: Drawable?, @@ -39,7 +39,7 @@ sealed class Extension { override val versionCode: Long, override val libVersion: Double, override val lang: String, - override val isNsfw: Boolean, + override val contentWarning: ContentWarning, val sources: List, val apkUrl: String, val iconUrl: String, @@ -70,6 +70,6 @@ sealed class Extension { override val libVersion: Double, val signatureHash: String, override val lang: String? = null, - override val isNsfw: Boolean = false, + override val contentWarning: ContentWarning = ContentWarning.UNSPECIFIED, ) : Extension() } diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 614735c59b4..b2f078eeb3b 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -268,6 +268,9 @@ NSFW (18+) sources Show in sources and extensions lists + Safe only + Safe & Mixed + Safe, Mixed & NSFW (18+) This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing NSFW (18+) content within the app. Today @@ -352,6 +355,8 @@ Age rating 18+ Sources from this extension may contain NSFW (18+) content + Mixed + Sources from this extension are mostly safe, but some of their content is NSFW (18+) Permissions are needed to install extensions. Tap here to grant. Installing extension… Installer