From e8cba5c1643c83694e245287b79cdecbcb5c4dfc Mon Sep 17 00:00:00 2001 From: Jays2Kings Date: Tue, 23 Mar 2021 14:12:31 -0400 Subject: [PATCH] Adding NSFW warning to extensions Co-Authored-By: arkon <4098258+arkon@users.noreply.github.com> --- .../eu/kanade/tachiyomi/annotations/Nsfw.kt | 5 + .../data/preference/PreferenceKeys.kt | 4 + .../data/preference/PreferencesHelper.kt | 4 + .../extension/api/ExtensionGithubApi.kt | 92 +++++++++---------- .../tachiyomi/extension/model/Extension.kt | 12 ++- .../extension/util/ExtensionLoader.kt | 85 +++++++++++++---- .../tachiyomi/ui/extension/ExtensionHolder.kt | 29 +++--- .../tachiyomi/ui/setting/PreferenceDSL.kt | 15 +++ .../ui/setting/SettingsBrowseController.kt | 33 ++++++- .../main/res/layout/extension_card_item.xml | 14 +++ app/src/main/res/values/strings.xml | 8 ++ 11 files changed, 216 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/annotations/Nsfw.kt diff --git a/app/src/main/java/eu/kanade/tachiyomi/annotations/Nsfw.kt b/app/src/main/java/eu/kanade/tachiyomi/annotations/Nsfw.kt new file mode 100644 index 0000000000..964a427025 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/annotations/Nsfw.kt @@ -0,0 +1,5 @@ +package eu.kanade.tachiyomi.annotations + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS) +annotation class Nsfw diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 89205ac271..8405397bbc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -159,6 +159,10 @@ object PreferenceKeys { const val enableDoh = "enable_doh" + const val showNsfwSource = "show_nsfw_source" + const val showNsfwExtension = "show_nsfw_extension" + const val labelNsfwExtension = "label_nsfw_extension" + fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 2d9d454e1e..92f684920a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -301,6 +301,10 @@ class PreferencesHelper(val context: Context) { fun hideBottomNavOnScroll() = flowPrefs.getBoolean(Keys.hideBottomNavOnScroll, true) + fun showNsfwSource() = flowPrefs.getBoolean(Keys.showNsfwSource, true) + fun showNsfwExtension() = flowPrefs.getBoolean(Keys.showNsfwExtension, true) + fun labelNsfwExtension() = prefs.getBoolean(Keys.labelNsfwExtension, true) + fun createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, true) fun enableDoh() = prefs.getBoolean(Keys.enableDoh, false) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index 79fff8b97e..373e0a859c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -1,90 +1,86 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context -import com.github.salomonbrys.kotson.fromJson -import com.github.salomonbrys.kotson.get -import com.github.salomonbrys.kotson.int -import com.github.salomonbrys.kotson.string -import com.google.gson.Gson -import com.google.gson.JsonArray import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.parseAs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import okhttp3.Response +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import uy.kohesive.injekt.injectLazy internal class ExtensionGithubApi { private val network: NetworkHelper by injectLazy() - private val gson: Gson by injectLazy() - suspend fun findExtensions(): List { - val call = GET("$REPO_URL/index.json") - return withContext(Dispatchers.IO) { - parseResponse(network.client.newCall(call).await()) + network.client + .newCall(GET("${REPO_URL_PREFIX}index.min.json")) + .await() + .parseAs() + .let { parseResponse(it) } } } suspend fun checkForUpdates(context: Context): List { return withContext(Dispatchers.IO) { - val call = GET("$REPO_URL/index.json") - val response = network.client.newCall(call).await() + val extensions = findExtensions() - if (response.isSuccessful) { - val extensions = parseResponse(response) - val extensionsWithUpdate = mutableListOf() + val installedExtensions = ExtensionLoader.loadExtensions(context) + .filterIsInstance() + .map { it.extension } - val installedExtensions = - ExtensionLoader.loadExtensions(context).filterIsInstance() - .map { it.extension } - val mutInstalledExtensions = installedExtensions.toMutableList() - for (installedExt in mutInstalledExtensions) { - val pkgName = installedExt.pkgName - val availableExt = extensions.find { it.pkgName == pkgName } ?: continue + val extensionsWithUpdate = mutableListOf() + val mutInstalledExtensions = installedExtensions.toMutableList() + for (installedExt in mutInstalledExtensions) { + val pkgName = installedExt.pkgName + val availableExt = extensions.find { it.pkgName == pkgName } ?: continue - val hasUpdate = availableExt.versionCode > installedExt.versionCode - if (hasUpdate) extensionsWithUpdate.add(installedExt) + val hasUpdate = availableExt.versionCode > installedExt.versionCode + if (hasUpdate) { + extensionsWithUpdate.add(installedExt) } - - extensionsWithUpdate - } else { - response.close() - throw Exception("Failed to get extensions") } + + extensionsWithUpdate } } - private fun parseResponse(response: Response): List { - val text = response.body?.use { it.string() } ?: return emptyList() + private fun parseResponse(json: kotlinx.serialization.json.JsonArray): List { + return json + .filter { element -> + val versionName = element.jsonObject["version"]!!.jsonPrimitive.content + val libVersion = versionName.substringBeforeLast('.').toDouble() + libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX + } + .map { element -> + val name = element.jsonObject["name"]!!.jsonPrimitive.content.substringAfter("Tachiyomi: ") + val pkgName = element.jsonObject["pkg"]!!.jsonPrimitive.content + val apkName = element.jsonObject["apk"]!!.jsonPrimitive.content + val versionName = element.jsonObject["version"]!!.jsonPrimitive.content + val versionCode = element.jsonObject["code"]!!.jsonPrimitive.int + val lang = element.jsonObject["lang"]!!.jsonPrimitive.content + val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1 + val icon = "${REPO_URL_PREFIX}icon/${apkName.replace(".apk", ".png")}" - val json = gson.fromJson(text) - - return json.map { element -> - val name = element["name"].string.substringAfter("Tachiyomi: ") - val pkgName = element["pkg"].string - val apkName = element["apk"].string - val versionName = element["version"].string - val versionCode = element["code"].int - val lang = element["lang"].string - val icon = "$REPO_URL/icon/${apkName.replace(".apk", ".png")}" - - Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon) + Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon) } } fun getApkUrl(extension: Extension.Available): String { - return "$REPO_URL/apk/${extension.apkName}" + return "${REPO_URL_PREFIX}apk/${extension.apkName}" } companion object { - private const val REPO_URL = - "https://raw.githubusercontent.com/inorichi/tachiyomi-extensions/repo" + private const val BASE_URL = "https://raw.githubusercontent.com/" + const val REPO_URL_PREFIX = "${BASE_URL}tachiyomiorg/tachiyomi-extensions/repo/" } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index 7fb384cc01..a08ed8480a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -9,16 +9,20 @@ sealed class Extension { abstract val versionName: String abstract val versionCode: Int abstract val lang: String? + abstract val isNsfw: Boolean data class Installed( override val name: String, override val pkgName: String, override val versionName: String, override val versionCode: Int, - val sources: List, override val lang: String, + override val isNsfw: Boolean, + val pkgFactory: String?, + val sources: List, val hasUpdate: Boolean = false, - val isObsolete: Boolean = false + val isObsolete: Boolean = false, + val isUnofficial: Boolean = false ) : Extension() data class Available( @@ -27,6 +31,7 @@ sealed class Extension { override val versionName: String, override val versionCode: Int, override val lang: String, + override val isNsfw: Boolean, val apkName: String, val iconUrl: String ) : Extension() @@ -37,6 +42,7 @@ sealed class Extension { override val versionName: String, override val versionCode: Int, val signatureHash: String, - override val lang: String? = null + override val lang: String? = null, + override val isNsfw: Boolean = false ) : Extension() } 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 8ba9847fba..6a3b79ff9c 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 @@ -5,6 +5,7 @@ import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager import dalvik.system.PathClassLoader +import eu.kanade.tachiyomi.annotations.Nsfw import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.extension.model.Extension @@ -16,8 +17,7 @@ import eu.kanade.tachiyomi.util.lang.Hash import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import timber.log.Timber -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy /** * Class that handles the loading of the extensions installed in the system. @@ -25,20 +25,27 @@ import uy.kohesive.injekt.api.get @SuppressLint("PackageManagerGetSignatures") internal object ExtensionLoader { + private val preferences: PreferencesHelper 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" - private const val LIB_VERSION_MIN = 1 - private const val LIB_VERSION_MAX = 1 + private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" + private const val METADATA_NSFW = "tachiyomi.extension.nsfw" + const val LIB_VERSION_MIN = 1.2 + const val LIB_VERSION_MAX = 1.2 private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES + // inorichi's key + private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" + /** * List of the trusted signatures. */ - var trustedSignatures = mutableSetOf() + - Injekt.get().trustedSignatures().getOrDefault() + - // inorichi's key - "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" + var trustedSignatures = mutableSetOf() + preferences.trustedSignatures().getOrDefault() + officialSignature /** * Return a list of all the installed extensions initialized concurrently. @@ -95,16 +102,21 @@ internal object ExtensionLoader { return LoadResult.Error(error) } - val extName = pkgManager.getApplicationLabel(appInfo)?.toString() - .orEmpty().substringAfter("Tachiyomi: ") + val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") val versionName = pkgInfo.versionName val versionCode = pkgInfo.versionCode + if (versionName.isNullOrEmpty()) { + val exception = Exception("Missing versionName for extension $extName") + Timber.w(exception) + return LoadResult.Error(exception) + } + // Validate lib version - val majorLibVersion = versionName.substringBefore('.').toInt() - if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) { + val libVersion = versionName.substringBeforeLast('.').toDouble() + if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { val exception = Exception( - "Lib version is $majorLibVersion, while only versions " + + "Lib version is $libVersion, while only versions " + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed" ) Timber.w(exception) @@ -121,6 +133,11 @@ internal object ExtensionLoader { return LoadResult.Untrusted(extension) } + val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1 + if (!loadNsfwSource && isNsfw) { + return LoadResult.Error("NSFW extension $pkgName not allowed") + } + val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader) val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!! @@ -134,10 +151,15 @@ internal object ExtensionLoader { } .flatMap { try { - val obj = Class.forName(it, false, classLoader).newInstance() - when (obj) { + when (val obj = Class.forName(it, false, classLoader).newInstance()) { is Source -> listOf(obj) - is SourceFactory -> obj.createSources() + is SourceFactory -> { + if (isSourceNsfw(obj)) { + emptyList() + } else { + obj.createSources() + } + } else -> throw Exception("Unknown source class type! ${obj.javaClass}") } } catch (e: Throwable) { @@ -145,6 +167,7 @@ internal object ExtensionLoader { return LoadResult.Error(e) } } + val langs = sources.filterIsInstance() .map { it.lang } .toSet() @@ -155,7 +178,17 @@ internal object ExtensionLoader { else -> "all" } - val extension = Extension.Installed(extName, pkgName, versionName, versionCode, sources, lang) + val extension = Extension.Installed( + extName, + pkgName, + versionName, + versionCode, + lang, + isNsfw, + sources = sources, + pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), + isUnofficial = signatureHash != officialSignature + ) return LoadResult.Success(extension) } @@ -181,4 +214,22 @@ internal object ExtensionLoader { null } } + + /** + * Checks whether a Source or SourceFactory is annotated with @Nsfw. + */ + private fun isSourceNsfw(clazz: Any): Boolean { + if (loadNsfwSource) { + return false + } + + if (clazz !is Source && clazz !is SourceFactory) { + return false + } + + // Annotations are proxied, hence this janky way of checking for them + return clazz.javaClass.annotations + .flatMap { it.javaClass.interfaces.map { it.simpleName } } + .firstOrNull { it == Nsfw::class.java.simpleName } != null + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt index 03dd190113..dcce041e44 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt @@ -14,8 +14,12 @@ import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.view.resetStrokeColor import eu.kanade.tachiyomi.data.image.coil.CoverViewTarget +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import kotlinx.android.synthetic.main.extension_card_item.* import kotlinx.android.synthetic.main.source_global_search_controller_card_item.* +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Locale class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : BaseFlexibleViewHolder(view, adapter) { @@ -26,17 +30,24 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : } } + private val shouldLabelNsfw by lazy { + Injekt.get().labelNsfwExtension() + } + fun bind(item: ExtensionItem) { val extension = item.extension // Set source name ext_title.text = extension.name version.text = extension.versionName - lang.text = if (extension !is Extension.Untrusted) { - LocaleHelper.getDisplayName(extension.lang, itemView.context) - } else { - itemView.context.getString(R.string.untrusted).toUpperCase() - } + lang.text = LocaleHelper.getDisplayName(extension.lang, itemView.context) + warning.text = when { + extension is Extension.Untrusted -> itemView.context.getString(R.string.untrusted) + extension is Extension.Installed && extension.isObsolete -> itemView.context.getString(R.string.obsolete) + extension is Extension.Installed && extension.isUnofficial -> itemView.context.getString(R.string.unofficial) + extension.isNsfw && shouldLabelNsfw -> itemView.context.getString(R.string.nsfw_short) + else -> "" + }.toUpperCase(Locale.ROOT) edit_button.clear() @@ -86,14 +97,8 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : strokeColor = ColorStateList.valueOf(Color.TRANSPARENT) setText(R.string.update) } - extension.isObsolete -> { - // Red outline - setTextColor(ContextCompat.getColorStateList(context, R.drawable.button_bg_error)) - - setText(R.string.obsolete) - } else -> { - setText(R.string.details) + setText(R.string.settings) } } } else if (extension is Extension.Untrusted) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt index 938b310cce..a8439f877b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/PreferenceDSL.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.setting import android.app.Activity +import androidx.annotation.StringRes import androidx.core.graphics.drawable.DrawableCompat import androidx.preference.CheckBoxPreference import androidx.preference.DialogPreference @@ -13,6 +14,8 @@ import androidx.preference.PreferenceManager import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.widget.preference.IntListMatPreference import eu.kanade.tachiyomi.widget.preference.ListMatPreference import eu.kanade.tachiyomi.widget.preference.MultiListMatPreference @@ -86,6 +89,18 @@ inline fun PreferenceScreen.preferenceCategory(block: (@DSL PreferenceCategory). ) } +inline fun PreferenceGroup.infoPreference(@StringRes infoRes: Int): Preference { + return initThenAdd( + Preference(context), + { + iconRes = R.drawable.ic_info_outline_24dp + iconTint = context.getResourceColor(android.R.attr.textColorSecondary) + summaryRes = infoRes + isSelectable = false + } + ) +} + inline fun PreferenceScreen.preferenceScreen(block: (@DSL PreferenceScreen).() -> Unit): PreferenceScreen { return addThenInit(preferenceManager.createPreferenceScreen(context), block) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt index e1a1a392cd..50375f898a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBrowseController.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.setting import androidx.preference.PreferenceScreen import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferenceKeys +import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.source.SourceManager @@ -11,6 +12,7 @@ import eu.kanade.tachiyomi.ui.migration.MigrationController import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.withFadeTransaction +import kotlinx.coroutines.flow.launchIn import uy.kohesive.injekt.injectLazy class SettingsBrowseController : SettingsController() { @@ -105,12 +107,33 @@ class SettingsBrowseController : SettingsController() { ) } } + + infoPreference(R.string.you_can_migrate_in_library) } - preference { - iconRes = R.drawable.ic_info_outline_24dp - iconTint = activity?.getResourceColor(android.R.attr.textColorSecondary) ?: 0 - summaryRes = R.string.you_can_migrate_in_library - isEnabled = false + + preferenceCategory { + titleRes = R.string.nsfw_sources + + switchPreference { + key = PreferenceKeys.showNsfwSource + titleRes = R.string.show_in_sources + summaryRes = R.string.requires_app_restart + defaultValue = true + } + switchPreference { + key = PreferenceKeys.showNsfwExtension + titleRes = R.string.show_in_extensions + defaultValue = true + } + switchPreference { + key = PreferenceKeys.labelNsfwExtension + titleRes = R.string.label_in_extensions + defaultValue = true + + preferences.showNsfwExtension().asImmediateFlow { isVisible = it }.launchIn(viewScope) + } + + infoPreference(R.string.does_not_prevent_unofficial_nsfw) } } } diff --git a/app/src/main/res/layout/extension_card_item.xml b/app/src/main/res/layout/extension_card_item.xml index 4034751ecf..9f7c5b485c 100644 --- a/app/src/main/res/layout/extension_card_item.xml +++ b/app/src/main/res/layout/extension_card_item.xml @@ -64,6 +64,20 @@ app:layout_constraintStart_toEndOf="@id/lang" tools:text="Version" /> + + + Version: %1$s Language: %1$s No preferences to edit for this extension + 18+ + Unofficial + May contain NSFW (18+) content Extension update available %d extension updates available @@ -622,6 +625,11 @@ You can also migrate by selecting manga in your library Source migration guide + NSFW (18+) sources + Show in sources list + Show in extensions list + Label in extensions list + This does not prevent unofficial or potentially incorrectly flagged extensions from surfacing NSFW (18+) content within the app. Version