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
+1 -1
View File
@@ -33,7 +33,7 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 29
versionCode = 30
versionName = "0.20.4"
buildConfigField("String", "COMMIT_COUNT", "\"${getLatestCommitCount()}\"")
@@ -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),
@@ -0,0 +1,31 @@
package mihon.core.migration.migrations
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoSet
import dev.zacsweers.metro.Inject
import eu.kanade.domain.source.service.SourcePreferences
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import mihon.domain.extension.model.ContentWarning
import tachiyomi.core.common.preference.PreferenceStore
@Inject
@ContributesIntoSet(AppScope::class)
class ContentWarningMigration(
private val preferenceStore: PreferenceStore,
private val sourcePreferences: SourcePreferences,
) : Migration {
override val version: Float = 30f
override suspend fun invoke(migrationContext: MigrationContext): Boolean {
val showNsfwSource = preferenceStore.getBoolean("show_nsfw_source", true)
if (!showNsfwSource.isSet()) return true
if (!showNsfwSource.get()) {
sourcePreferences.enabledContentWarnings.set(setOf(ContentWarning.SAFE))
}
showNsfwSource.delete()
return true
}
}
@@ -8,6 +8,7 @@ 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.Extension as TachiyomiExtension
import mihon.domain.extension.model.ContentWarning as DomainContentWarning
@SuppressLint("UnsafeOptInUsageError")
@Serializable
@@ -104,7 +105,12 @@ fun ExtensionList.toAvailableExtensions(store: ExtensionStore): List<TachiyomiEx
versionCode = extension.versionCode,
versionName = extension.versionName,
lang = if (lang.size == 1) lang.first() else "all",
isNsfw = extension.contentWarning >= ContentWarning.MIXED,
contentWarning = when (extension.contentWarning) {
ContentWarning.SAFE -> DomainContentWarning.SAFE
ContentWarning.MIXED -> DomainContentWarning.MIXED
ContentWarning.NSFW -> DomainContentWarning.NSFW
else -> DomainContentWarning.SAFE
},
sources = extension.sources.map { source ->
TachiyomiExtension.Available.Source(
id = source.id,
@@ -3,6 +3,7 @@ package mihon.data.extension.model
import android.annotation.SuppressLint
import eu.kanade.tachiyomi.extension.model.Extension
import kotlinx.serialization.Serializable
import mihon.domain.extension.model.ContentWarning
import mihon.domain.extension.model.ExtensionStore
@SuppressLint("UnsafeOptInUsageError")
@@ -35,7 +36,7 @@ data class NetworkLegacyExtension(
versionCode = code,
versionName = version,
lang = lang,
isNsfw = nsfw == 1,
contentWarning = if (nsfw == 1) ContentWarning.NSFW else ContentWarning.SAFE,
sources = if (sources.isNullOrEmpty()) {
listOf(
Extension.Available.Source(
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.model
import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.source.Source
import mihon.domain.extension.model.ContentWarning
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.domain.source.model.StubSource
@@ -13,7 +14,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 +23,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<Source>,
val icon: Drawable?,
@@ -39,7 +40,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<Source>,
val apkUrl: String,
val iconUrl: String,
@@ -70,6 +71,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.SAFE,
) : Extension()
}
@@ -0,0 +1,7 @@
package mihon.domain.extension.model
enum class ContentWarning {
SAFE,
MIXED,
NSFW,
}
@@ -266,9 +266,9 @@
<string name="secure_screen_summary">Secure screen hides app contents when switching apps and block screenshots</string>
<string name="firebase_summary">Sending crash logs and analytics will allow us to identify and fix issues, improve performance, and make future updates more relevant to your needs</string>
<string name="pref_category_nsfw_content">NSFW (18+) sources</string>
<string name="pref_show_nsfw_source">Show in sources and extensions lists</string>
<string name="parental_controls_info">This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing NSFW (18+) content within the app.</string>
<string name="pref_category_extensions">Extensions</string>
<string name="pref_allowed_content_warnings">Allowed content warnings</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="relative_time_today">Today</string>
@@ -349,9 +349,12 @@
<string name="extension_api_error">Failed to fetch available extensions</string>
<string name="ext_info_version">Version</string>
<string name="ext_info_language">Language</string>
<string name="ext_info_age_rating">Age rating</string>
<string name="ext_nsfw_short">18+</string>
<string name="ext_nsfw_warning">Sources from this extension may contain NSFW (18+) content</string>
<string name="ext_info_warning">Warning</string>
<string name="ext_content_warning_safe">Safe</string>
<string name="ext_content_warning_mixed">Mixed</string>
<string name="ext_content_warning_nsfw">18+</string>
<string name="ext_content_warning_mixed_description">Sources from this extension might contain both safe and 18+ content.</string>
<string name="ext_content_warning_nsfw_description">Sources from this extension are primarily focused on 18+ content.</string>
<string name="ext_permission_install_apps_warning">Permissions are needed to install extensions. Tap here to grant.</string>
<string name="ext_install_service_notif">Installing extension…</string>
<string name="ext_installer_pref">Installer</string>