Option to hide nsfw content

This commit is contained in:
Koitharu
2023-08-11 14:50:56 +03:00
parent 03cb458d92
commit caebca36de
12 changed files with 104 additions and 129 deletions

View File

@@ -9,7 +9,6 @@ import android.provider.Settings
import androidx.annotation.FloatRange
import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.ArraySet
import androidx.collection.arraySetOf
import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.preference.PreferenceManager
@@ -56,6 +55,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
var isNsfwContentDisabled: Boolean
get() = prefs.getBoolean(KEY_DISABLE_NSFW, false)
set(value) = prefs.edit { putBoolean(KEY_DISABLE_NSFW, value) }
var appLocales: LocaleListCompat
get() {
val raw = prefs.getString(KEY_APP_LOCALE, null)
@@ -444,6 +447,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_PROXY_PASSWORD = "proxy_password"
const val KEY_IMAGES_PROXY = "images_proxy"
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -4,13 +4,17 @@ import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import java.util.Collections
@@ -20,6 +24,7 @@ import javax.inject.Inject
@Reusable
class MangaSourcesRepository @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
) {
private val dao: MangaSourcesDao
@@ -36,11 +41,13 @@ class MangaSourcesRepository @Inject constructor(
get() = Collections.unmodifiableSet(remoteSources)
suspend fun getEnabledSources(): List<MangaSource> {
return dao.findAllEnabled().toSources()
return dao.findAllEnabled().toSources(settings.isNsfwContentDisabled)
}
fun observeEnabledSources(): Flow<List<MangaSource>> = dao.observeEnabled().map {
it.toSources()
fun observeEnabledSources(): Flow<List<MangaSource>> = observeIsNsfwDisabled().flatMapLatest { skipNsfw ->
dao.observeEnabled().map {
it.toSources(skipNsfw)
}
}
fun observeAll(): Flow<List<Pair<MangaSource, Boolean>>> = dao.observeAll().map { entities ->
@@ -137,14 +144,21 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAll().isEmpty()
}
private fun List<MangaSourceEntity>.toSources(): List<MangaSource> {
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> {
val result = ArrayList<MangaSource>(size)
for (entity in this) {
val source = MangaSource(entity.source)
if (skipNsfwSources && source.contentType == ContentType.HENTAI) {
continue
}
if (source in remoteSources) {
result.add(source)
}
}
return result
}
private fun observeIsNsfwDisabled() = settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) {
isNsfwContentDisabled
}
}

View File

@@ -19,7 +19,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
abstract class MangaListViewModel(
settings: AppSettings,
private val settings: AppSettings,
private val downloadScheduler: DownloadWorker.Scheduler,
) : BaseViewModel() {
@@ -46,4 +46,10 @@ abstract class MangaListViewModel(
onDownloadStarted.call(Unit)
}
}
fun List<Manga>.skipNsfwIfNeeded() = if (settings.isNsfwContentDisabled) {
filterNot { it.isNsfw }
} else {
this
}
}

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
@@ -66,7 +67,7 @@ open class RemoteListViewModel @Inject constructor(
private var randomJob: Job? = null
override val content = combine(
mangaList,
mangaList.map { it?.skipNsfwIfNeeded() },
listMode,
listError,
hasNextPage,

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.search.domain
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.Context
import android.provider.SearchRecentSuggestions
@@ -8,19 +7,18 @@ import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import javax.inject.Inject
@@ -30,34 +28,22 @@ class MangaSearchRepository @Inject constructor(
private val sourcesRepository: MangaSourcesRepository,
@ApplicationContext private val context: Context,
private val recentSuggestions: SearchRecentSuggestions,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
) {
fun globalSearch(query: String, concurrency: Int = DEFAULT_CONCURRENCY): Flow<Manga> =
flow {
emitAll(sourcesRepository.getEnabledSources().asFlow())
}.flatMapMerge(concurrency) { source ->
runCatchingCancellable {
mangaRepositoryFactory.create(source).getList(
offset = 0,
query = query,
)
}.getOrElse {
emptyList()
}.asFlow()
}.filter {
match(it, query)
}
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
if (query.isEmpty()) {
return emptyList()
}
val skipNsfw = settings.isNsfwContentDisabled
return if (source != null) {
db.mangaDao.searchByTitle("%$query%", source.name, limit)
} else {
db.mangaDao.searchByTitle("%$query%", limit)
}.map { it.toManga() }
}.let {
if (skipNsfw) it.filterNot { x -> x.manga.isNsfw } else it
}
.map { it.toManga() }
.sortedBy { x -> x.title.levenshteinDistance(query) }
}
@@ -67,7 +53,7 @@ class MangaSearchRepository @Inject constructor(
): List<String> = withContext(Dispatchers.IO) {
context.contentResolver.query(
MangaSuggestionsProvider.QUERY_URI,
SUGGESTION_PROJECTION,
arrayOf(SearchManager.SUGGEST_COLUMN_QUERY),
"${SearchManager.SUGGEST_COLUMN_QUERY} LIKE ?",
arrayOf("%$query%"),
"date DESC",
@@ -102,8 +88,11 @@ class MangaSearchRepository @Inject constructor(
if (query.length < 3) {
return emptyList()
}
val skipNsfw = settings.isNsfwContentDisabled
val sources = sourcesRepository.allMangaSources
.filter { x -> x.title.contains(query, ignoreCase = true) }
.filter { x ->
(x.contentType != ContentType.HENTAI || !skipNsfw) && x.title.contains(query, ignoreCase = true)
}
return if (limit == 0) {
sources
} else {
@@ -130,33 +119,10 @@ class MangaSearchRepository @Inject constructor(
suspend fun getSearchHistoryCount(): Int = withContext(Dispatchers.IO) {
context.contentResolver.query(
MangaSuggestionsProvider.QUERY_URI,
SUGGESTION_PROJECTION,
arrayOf(SearchManager.SUGGEST_COLUMN_QUERY),
null,
arrayOfNulls(1),
null,
)?.use { cursor -> cursor.count } ?: 0
}
private companion object {
private val REGEX_SPACE = Regex("\\s+")
val SUGGESTION_PROJECTION = arrayOf(SearchManager.SUGGEST_COLUMN_QUERY)
@SuppressLint("DefaultLocale")
fun match(manga: Manga, query: String): Boolean {
val words = HashSet<String>()
words += manga.title.lowercase().split(REGEX_SPACE)
words += manga.altTitle?.lowercase()?.split(REGEX_SPACE).orEmpty()
val words2 = query.lowercase().split(REGEX_SPACE).toSet()
for (w in words) {
for (w2 in words2) {
val diff = w.levenshteinDistance(w2) / ((w.length + w2.length) / 2f)
if (diff < 0.5) {
return true
}
}
}
return false
}
}
}

View File

@@ -9,8 +9,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import javax.inject.Inject
@@ -18,6 +20,7 @@ import javax.inject.Inject
@HiltViewModel
class NewSourcesViewModel @Inject constructor(
private val repository: MangaSourcesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
private val newSources = SuspendLazy {
@@ -26,9 +29,16 @@ class NewSourcesViewModel @Inject constructor(
val content: StateFlow<List<SourceConfigItem>> = repository.observeAll()
.map { sources ->
val new = newSources.get()
val skipNsfw = settings.isNsfwContentDisabled
sources.mapNotNull { (source, enabled) ->
if (source in new) {
SourceConfigItem.SourceItem(source, enabled, source.getLocaleTitle(), false)
SourceConfigItem.SourceItem(
source = source,
isEnabled = enabled,
summary = source.getLocaleTitle(),
isDraggable = false,
isAvailable = !skipNsfw || source.contentType != ContentType.HENTAI,
)
} else {
null
}

View File

@@ -16,6 +16,7 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
@@ -41,6 +42,9 @@ class SourcesManageFragment :
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var reorderHelper: ItemTouchHelper? = null
private val viewModel by viewModels<SourcesManageViewModel>()
@@ -128,9 +132,19 @@ class SourcesManageFragment :
true
}
R.id.action_no_nsfw -> {
settings.isNsfwContentDisabled = !menuItem.isChecked
true
}
else -> false
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_no_nsfw).isChecked = settings.isNsfwContentDisabled
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.toEnumSet
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
@@ -54,8 +55,9 @@ class SourcesManageViewModel @Inject constructor(
expandedGroups,
searchQuery,
observeTip(),
) { sources, groups, query, tip ->
buildList(sources, groups, query, tip)
settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled },
) { sources, groups, query, tip, noNsfw ->
buildList(sources, groups, query, tip, noNsfw)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -125,6 +127,7 @@ class SourcesManageViewModel @Inject constructor(
expanded: Set<String?>,
query: String?,
withTip: Boolean,
isNsfwDisabled: Boolean,
): List<SourceConfigItem> {
val allSources = repository.allMangaSources
val enabledSet = enabledSources.toEnumSet()
@@ -138,6 +141,7 @@ class SourcesManageViewModel @Inject constructor(
summary = it.getLocaleTitle(),
isEnabled = it in enabledSet,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult)
@@ -163,6 +167,7 @@ class SourcesManageViewModel @Inject constructor(
summary = it.getLocaleTitle(),
isEnabled = true,
isDraggable = true,
isAvailable = false,
)
}
}
@@ -184,6 +189,7 @@ class SourcesManageViewModel @Inject constructor(
summary = null,
isEnabled = false,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}
}
@@ -210,6 +216,8 @@ class SourcesManageViewModel @Inject constructor(
isTipEnabled(TIP_REORDER)
}
private fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()

View File

@@ -72,8 +72,17 @@ fun sourceConfigItemCheckableDelegate(
}
bind {
binding.textViewTitle.text = item.source.title
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
binding.switchToggle.isChecked = item.isEnabled
binding.switchToggle.isEnabled = item.isAvailable
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
@@ -120,7 +129,7 @@ fun sourceConfigItemDelegate2(
} else {
item.source.title
}
binding.imageViewAdd.isGone = item.isEnabled
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
binding.imageViewRemove.isVisible = item.isEnabled
binding.imageViewConfig.isVisible = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary

View File

@@ -9,25 +9,16 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
sealed interface SourceConfigItem : ListModel {
class Header(
data class Header(
@StringRes val titleResId: Int,
) : SourceConfigItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Header && other.titleResId == titleResId
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Header
return titleResId == other.titleResId
}
override fun hashCode(): Int = titleResId
}
class LocaleGroup(
data class LocaleGroup(
val localeId: String?,
val title: String?,
val isExpanded: Boolean,
@@ -44,31 +35,14 @@ sealed interface SourceConfigItem : ListModel {
super.getChangePayload(previousState)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as LocaleGroup
if (localeId != other.localeId) return false
if (title != other.title) return false
return isExpanded == other.isExpanded
}
override fun hashCode(): Int {
var result = localeId?.hashCode() ?: 0
result = 31 * result + (title?.hashCode() ?: 0)
result = 31 * result + isExpanded.hashCode()
return result
}
}
class SourceItem(
data class SourceItem(
val source: MangaSource,
val isEnabled: Boolean,
val summary: String?,
val isDraggable: Boolean,
val isAvailable: Boolean,
) : SourceConfigItem {
val isNsfw: Boolean
@@ -85,29 +59,9 @@ sealed interface SourceConfigItem : ListModel {
super.getChangePayload(previousState)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SourceItem
if (source != other.source) return false
if (summary != other.summary) return false
if (isEnabled != other.isEnabled) return false
return isDraggable == other.isDraggable
}
override fun hashCode(): Int {
var result = source.hashCode()
result = 31 * result + summary.hashCode()
result = 31 * result + isEnabled.hashCode()
result = 31 * result + isDraggable.hashCode()
return result
}
}
class Tip(
data class Tip(
val key: String,
@DrawableRes val iconResId: Int,
@StringRes val textResId: Int,
@@ -116,24 +70,6 @@ sealed interface SourceConfigItem : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Tip && other.key == key
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Tip
if (key != other.key) return false
if (iconResId != other.iconResId) return false
return textResId == other.textResId
}
override fun hashCode(): Int {
var result = key.hashCode()
result = 31 * result + iconResId
result = 31 * result + textResId
return result
}
}
object EmptySearchResult : SourceConfigItem {

View File

@@ -10,6 +10,12 @@
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="ifRoom|collapseActionView" />
<item
android:id="@+id/action_no_nsfw"
android:checkable="true"
android:title="@string/disable_nsfw"
app:showAsAction="never" />
<item
android:id="@+id/action_locales"
android:title="@string/languages"

View File

@@ -472,4 +472,5 @@
<string name="languages">Languages</string>
<string name="unknown">Unknown</string>
<string name="in_progress">In progress</string>
<string name="disable_nsfw">Disable NSFW</string>
</resources>