Replace extension NSFW flag with content warning app-wide (#3951)
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
+27
-10
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user