Merge branch 'feature/suggestions_v2' into devel

This commit is contained in:
Koitharu
2023-05-11 11:47:21 +03:00
21 changed files with 413 additions and 105 deletions

View File

@@ -9,6 +9,8 @@ import org.koitharu.kotatsu.utils.ext.iterator
fun Collection<Manga>.ids() = mapToSet { it.id } fun Collection<Manga>.ids() = mapToSet { it.id }
fun Collection<Manga>.distinctById() = distinctBy { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int { fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) { if (size <= 1) {
return size return size

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.shelf.domain.ShelfSection import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.connectivityManager import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.filterToSet import org.koitharu.kotatsu.utils.ext.filterToSet
@@ -244,12 +245,25 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDownloadsWiFiOnly: Boolean val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val isSuggestionsEnabled: Boolean var isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false) get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
val isSuggestionsExcludeNsfw: Boolean val isSuggestionsExcludeNsfw: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false)
val isSuggestionsNotificationAvailable: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true)
val suggestionsTagsBlacklist: Set<String>
get() {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return emptySet()
}
return string.split(',').mapToSet { it.trim() }
}
val isReaderBarEnabled: Boolean val isReaderBarEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_BAR, true) get() = prefs.getBoolean(KEY_READER_BAR, true)
@@ -279,18 +293,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
fun getSuggestionsTagsBlacklistRegex(): Regex? {
val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',')
if (string.isNullOrEmpty()) {
return null
}
val tags = string.split(',')
val regex = tags.joinToString(prefix = "(", separator = "|", postfix = ")") { tag ->
Regex.escape(tag.trim())
}
return Regex(regex, RegexOption.IGNORE_CASE)
}
fun getMangaSources(includeHidden: Boolean): List<MangaSource> { fun getMangaSources(includeHidden: Boolean): List<MangaSource> {
val list = remoteSources.toMutableList() val list = remoteSources.toMutableList()
val order = sourcesOrder val order = sourcesOrder
@@ -381,6 +383,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS = "suggestions" const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw" const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags" const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.explore.domain package org.koitharu.kotatsu.explore.domain
import javax.inject.Inject
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
import org.koitharu.kotatsu.utils.ext.almostEquals
import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
class ExploreRepository @Inject constructor( class ExploreRepository @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
@@ -14,29 +19,47 @@ class ExploreRepository @Inject constructor(
) { ) {
suspend fun findRandomManga(tagsLimit: Int): Manga { suspend fun findRandomManga(tagsLimit: Int): Manga {
val blacklistTagRegex = settings.getSuggestionsTagsBlacklistRegex() val blacklistTagRegex = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f)
val allTags = historyRepository.getPopularTags(tagsLimit).filterNot { val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
blacklistTagRegex?.containsMatchIn(it.title) ?: false if (it in blacklistTagRegex) null else it.title
} }
val tag = allTags.randomOrNull() val sources = settings.getMangaSources(includeHidden = false)
val source = checkNotNull(tag?.source ?: settings.getMangaSources(includeHidden = false).randomOrNull()) { check(sources.isNotEmpty()) { "No sources available" }
"No sources found" for (i in 0..4) {
} val list = getList(sources.random(), tags, blacklistTagRegex)
val repo = mangaRepositoryFactory.create(source) val manga = list.randomOrNull() ?: continue
val list = repo.getList( val details = runCatchingCancellable {
offset = 0, mangaRepositoryFactory.create(manga.source).getDetails(manga)
sortOrder = if (SortOrder.UPDATED in repo.sortOrders) SortOrder.UPDATED else null, }.getOrNull() ?: continue
tags = setOfNotNull(tag), if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in blacklistTagRegex) {
).shuffled()
for (item in list) {
if (settings.isSuggestionsExcludeNsfw && item.isNsfw) {
continue continue
} }
if (blacklistTagRegex != null && item.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }) { return details
continue
}
return item
} }
return list.random() throw NoSuchElementException()
} }
private suspend fun getList(
source: MangaSource,
tags: List<String>,
blacklist: TagsBlacklist,
): List<Manga> = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(source)
val order = repository.sortOrders.random()
val availableTags = repository.getTags()
val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
}
val list = repository.getList(0, setOfNotNull(tag), order).asArrayList()
if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw }
}
if (blacklist.isNotEmpty()) {
list.removeAll { manga -> manga in blacklist }
}
list.shuffle()
list
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(emptyList())
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.explore.ui package org.koitharu.kotatsu.explore.ui
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
@@ -16,6 +17,7 @@ import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
@@ -76,6 +78,9 @@ class ExploreFragment :
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga) viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged) viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) {
showSuggestionsTip()
}
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -143,6 +148,19 @@ class ExploreFragment :
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
private fun showSuggestionsTip() {
val listener = DialogInterface.OnClickListener { _, which ->
viewModel.respondSuggestionTip(which == DialogInterface.BUTTON_POSITIVE)
}
TwoButtonsAlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_suggestion)
.setTitle(R.string.suggestions_enable_prompt)
.setPositiveButton(R.string.enable, listener)
.setNegativeButton(R.string.no_thanks, listener)
.create()
.show()
}
private inner class SourceMenuListener( private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source, private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener { ) : PopupMenu.OnMenuItemClickListener {

View File

@@ -6,7 +6,6 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -27,6 +26,8 @@ import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData import org.koitharu.kotatsu.utils.asFlowLiveData
import javax.inject.Inject import javax.inject.Inject
private const val TIP_SUGGESTIONS = "suggestions"
@HiltViewModel @HiltViewModel
class ExploreViewModel @Inject constructor( class ExploreViewModel @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
@@ -41,6 +42,7 @@ class ExploreViewModel @Inject constructor(
val onOpenManga = SingleLiveEvent<Manga>() val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>() val onActionDone = SingleLiveEvent<ReversibleAction>()
val onShowSuggestionsTip = SingleLiveEvent<Unit>()
val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext) val isGrid = gridMode.asFlowLiveData(viewModelScope.coroutineContext)
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading -> val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
@@ -51,6 +53,14 @@ class ExploreViewModel @Inject constructor(
} }
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading)) }.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(ExploreItem.Loading))
init {
launchJob(Dispatchers.Default) {
if (!settings.isSuggestionsEnabled && settings.isTipEnabled(TIP_SUGGESTIONS)) {
onShowSuggestionsTip.emitCall(Unit)
}
}
}
fun openRandom() { fun openRandom() {
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val manga = exploreRepository.findRandomManga(tagsLimit = 8) val manga = exploreRepository.findRandomManga(tagsLimit = 8)
@@ -72,6 +82,11 @@ class ExploreViewModel @Inject constructor(
settings.isSourcesGridMode = value settings.isSourcesGridMode = value
} }
fun respondSuggestionTip(isAccepted: Boolean) {
settings.isSuggestionsEnabled = isAccepted
settings.closeTip(TIP_SUGGESTIONS)
}
private fun createContentFlow() = settings.observe() private fun createContentFlow() = settings.observe()
.filter { .filter {
it == AppSettings.KEY_SOURCES_HIDDEN || it == AppSettings.KEY_SOURCES_HIDDEN ||
@@ -80,7 +95,6 @@ class ExploreViewModel @Inject constructor(
} }
.onStart { emit("") } .onStart { emit("") }
.map { settings.getMangaSources(includeHidden = false) } .map { settings.getMangaSources(includeHidden = false) }
.distinctUntilChanged()
.combine(gridMode) { content, grid -> buildList(content, grid) } .combine(gridMode) { content, grid -> buildList(content, grid) }
private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> { private fun buildList(sources: List<MangaSource>, isGrid: Boolean): List<ExploreItem> {

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.favourites.data package org.koitharu.kotatsu.favourites.data
import org.koitharu.kotatsu.core.db.entity.SortOrder import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import java.util.* import java.util.Date
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory( fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id, id = id,
@@ -13,4 +15,8 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
createdAt = Date(createdAt), createdAt = Date(createdAt),
isTrackingEnabled = track, isTrackingEnabled = track,
isVisibleInLibrary = isVisibleInLibrary, isVisibleInLibrary = isVisibleInLibrary,
) )
fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
fun Collection<FavouriteManga>.toMangaList() = map { it.toManga() }

View File

@@ -17,6 +17,10 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC") @Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(): List<FavouriteManga> abstract suspend fun findAll(): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> { fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order) val orderBy = getOrderBy(order)

View File

@@ -12,12 +12,12 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.SortOrder import org.koitharu.kotatsu.core.db.entity.SortOrder
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toManga
import org.koitharu.kotatsu.favourites.data.toMangaList
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@@ -32,22 +32,27 @@ class FavouritesRepository @Inject constructor(
suspend fun getAllManga(): List<Manga> { suspend fun getAllManga(): List<Manga> {
val entities = db.favouritesDao.findAll() val entities = db.favouritesDao.findAll()
return entities.map { it.manga.toManga(it.tags.toMangaTags()) } return entities.toMangaList()
}
suspend fun getLastManga(limit: Int): List<Manga> {
val entities = db.favouritesDao.findLast(limit)
return entities.toMangaList()
} }
fun observeAll(order: SortOrder): Flow<List<Manga>> { fun observeAll(order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(order) return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.toMangaTags()) } .mapItems { it.toManga() }
} }
suspend fun getManga(categoryId: Long): List<Manga> { suspend fun getManga(categoryId: Long): List<Manga> {
val entities = db.favouritesDao.findAll(categoryId) val entities = db.favouritesDao.findAll(categoryId)
return entities.map { it.manga.toManga(it.tags.toMangaTags()) } return entities.toMangaList()
} }
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> { fun observeAll(categoryId: Long, order: SortOrder): Flow<List<Manga>> {
return db.favouritesDao.observeAll(categoryId, order) return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.toMangaTags()) } .mapItems { it.toManga() }
} }
fun observeAll(categoryId: Long): Flow<List<Manga>> { fun observeAll(categoryId: Long): Flow<List<Manga>> {

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.suggestions.domain package org.koitharu.kotatsu.suggestions.domain
import androidx.room.withTransaction import androidx.room.withTransaction
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
@@ -11,6 +10,7 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.utils.ext.mapItems import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject
class SuggestionRepository @Inject constructor( class SuggestionRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.suggestions.domain
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.utils.ext.almostEquals
class TagsBlacklist(
private val tags: Set<String>,
private val threshold: Float,
) {
fun isNotEmpty() = tags.isNotEmpty()
operator fun contains(manga: Manga): Boolean {
if (tags.isEmpty()) {
return false
}
for (mangaTag in manga.tags) {
for (tagTitle in tags) {
if (mangaTag.title.almostEquals(tagTitle, threshold)) {
return true
}
}
}
return false
}
operator fun contains(tag: MangaTag): Boolean = tags.any {
it.almostEquals(tag.title, threshold)
}
}

View File

@@ -2,11 +2,15 @@ package org.koitharu.kotatsu.suggestions.ui
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.app.PendingIntentCompat
import androidx.core.text.HtmlCompat
import androidx.core.text.buildSpannedString
import androidx.core.text.parseAsHtml
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.Constraints import androidx.work.Constraints
@@ -20,39 +24,56 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.workDataOf import androidx.work.workDataOf
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.async import kotlinx.coroutines.flow.map
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.take
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.distinctById
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
import org.koitharu.kotatsu.utils.ext.almostEquals
import org.koitharu.kotatsu.utils.ext.asArrayList import org.koitharu.kotatsu.utils.ext.asArrayList
import org.koitharu.kotatsu.utils.ext.flatten
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import org.koitharu.kotatsu.utils.ext.takeMostFrequent
import org.koitharu.kotatsu.utils.ext.toBitmapOrNull
import org.koitharu.kotatsu.utils.ext.trySetForeground import org.koitharu.kotatsu.utils.ext.trySetForeground
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.pow import kotlin.math.pow
import kotlin.random.Random
@HiltWorker @HiltWorker
class SuggestionsWorker @AssistedInject constructor( class SuggestionsWorker @AssistedInject constructor(
@Assisted appContext: Context, @Assisted appContext: Context,
@Assisted params: WorkerParameters, @Assisted params: WorkerParameters,
private val coil: ImageLoader,
private val suggestionRepository: SuggestionRepository, private val suggestionRepository: SuggestionRepository,
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val appSettings: AppSettings, private val appSettings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
trySetForeground()
val count = doWorkImpl() val count = doWorkImpl()
val outputData = workDataOf(DATA_COUNT to count) val outputData = workDataOf(DATA_COUNT to count)
return Result.success(outputData) return Result.success(outputData)
@@ -93,83 +114,197 @@ class SuggestionsWorker @AssistedInject constructor(
suggestionRepository.clear() suggestionRepository.clear()
return 0 return 0
} }
val blacklistTagRegex = appSettings.getSuggestionsTagsBlacklistRegex() val seed = (
val allTags = historyRepository.getPopularTags(TAGS_LIMIT).filterNot { historyRepository.getList(0, 20) +
blacklistTagRegex?.containsMatchIn(it.title) ?: false favouritesRepository.getLastManga(20)
} ).distinctById()
if (allTags.isEmpty()) { val sources = appSettings.getMangaSources(includeHidden = false)
if (seed.isEmpty() || sources.isEmpty()) {
return 0 return 0
} }
if (TAG in tags) { // not expedited val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, TAG_EQ_THRESHOLD)
trySetForeground() val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10)
}
val tagsBySources = allTags.groupBy { x -> x.source } val producer = channelFlow {
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM) for (it in sources.shuffled()) {
val rawResults = coroutineScope { launch {
tagsBySources.flatMap { (source, tags) -> send(getList(it, tags, tagsBlacklist))
val repo = mangaRepositoryFactory.tryCreate(source) ?: return@flatMap emptyList()
tags.map { tag ->
async(dispatcher) {
repo.getListSafe(tag)
}
} }
}.awaitAll().flatten().asArrayList()
}
if (appSettings.isSuggestionsExcludeNsfw) {
rawResults.removeAll { it.isNsfw }
}
if (blacklistTagRegex != null) {
rawResults.removeAll {
it.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }
} }
} }
if (rawResults.isEmpty()) { val suggestions = producer
return 0 .flatten()
} .take(MAX_RAW_RESULTS)
val suggestions = rawResults.distinctBy { manga -> .map { manga ->
manga.id MangaSuggestion(
}.map { manga -> manga = manga,
MangaSuggestion( relevance = computeRelevance(manga.tags, tags),
manga = manga, )
relevance = computeRelevance(manga.tags, allTags), }.toList()
) .sortedBy { it.relevance }
}.sortedBy { it.relevance }.take(LIMIT) .take(MAX_RESULTS)
suggestionRepository.replace(suggestions) suggestionRepository.replace(suggestions)
if (appSettings.isSuggestionsNotificationAvailable) {
runCatchingCancellable {
val manga = suggestions[Random.nextInt(0, suggestions.size / 3)]
val details = mangaRepositoryFactory.create(manga.manga.source)
.getDetails(manga.manga)
if (details !in tagsBlacklist) {
showNotification(details)
}
}.onFailure {
it.printStackTraceDebug()
}
}
return suggestions.size return suggestions.size
} }
private suspend fun getList(
source: MangaSource,
tags: List<String>,
blacklist: TagsBlacklist,
): List<Manga> = runCatchingCancellable {
val repository = mangaRepositoryFactory.create(source)
val availableOrders = repository.sortOrders
val order = preferredSortOrders.first { it in availableOrders }
val availableTags = repository.getTags()
val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, TAG_EQ_THRESHOLD) }
}
val list = repository.getList(0, setOfNotNull(tag), order).asArrayList()
if (appSettings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw }
}
if (blacklist.isNotEmpty()) {
list.removeAll { manga -> manga in blacklist }
}
list
}.onFailure {
it.printStackTraceDebug()
}.getOrDefault(emptyList())
private suspend fun showNotification(manga: Manga) {
val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
MANGA_CHANNEL_ID,
applicationContext.getString(R.string.suggestions),
NotificationManager.IMPORTANCE_DEFAULT,
)
channel.description = applicationContext.getString(R.string.suggestions_summary)
channel.enableLights(true)
channel.setShowBadge(true)
manager.createNotificationChannel(channel)
}
val id = manga.url.hashCode()
val title = applicationContext.getString(R.string.suggestion_manga, manga.title)
val builder = NotificationCompat.Builder(applicationContext, MANGA_CHANNEL_ID)
val tagsText = manga.tags.joinToString(", ") { it.title }
with(builder) {
setContentText(tagsText)
setContentTitle(title)
setLargeIcon(
coil.execute(
ImageRequest.Builder(applicationContext)
.data(manga.coverUrl)
.tag(manga.source)
.build(),
).toBitmapOrNull(),
)
setSmallIcon(R.drawable.ic_stat_suggestion)
val description = manga.description?.parseAsHtml(HtmlCompat.FROM_HTML_MODE_COMPACT)
if (!description.isNullOrBlank()) {
val style = NotificationCompat.BigTextStyle()
style.bigText(
buildSpannedString {
append(tagsText)
appendLine()
append(description)
},
)
style.setBigContentTitle(title)
setStyle(style)
}
val intent = DetailsActivity.newIntent(applicationContext, manga)
setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
id,
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
),
)
setAutoCancel(true)
setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
setVisibility(if (manga.isNsfw) NotificationCompat.VISIBILITY_SECRET else NotificationCompat.VISIBILITY_PUBLIC)
setShortcutId(manga.id.toString())
priority = NotificationCompat.PRIORITY_DEFAULT
addAction(
R.drawable.ic_read,
applicationContext.getString(R.string.read),
PendingIntentCompat.getActivity(
applicationContext,
id + 2,
ReaderActivity.newIntent(applicationContext, manga),
0,
false,
),
)
addAction(
R.drawable.ic_suggestion,
applicationContext.getString(R.string.more),
PendingIntentCompat.getActivity(
applicationContext,
0,
SuggestionsActivity.newIntent(applicationContext),
0,
false,
),
)
}
manager.notify(TAG, id, builder.build())
}
@FloatRange(from = 0.0, to = 1.0) @FloatRange(from = 0.0, to = 1.0)
private fun computeRelevance(mangaTags: Set<MangaTag>, allTags: List<MangaTag>): Float { private fun computeRelevance(mangaTags: Set<MangaTag>, allTags: List<String>): Float {
val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0 val maxWeight = (allTags.size + allTags.size + 1 - mangaTags.size) * mangaTags.size / 2.0
val weight = mangaTags.sumOf { tag -> val weight = mangaTags.sumOf { tag ->
val index = allTags.indexOf(tag) val index = allTags.inexactIndexOf(tag.title, TAG_EQ_THRESHOLD)
if (index < 0) 0 else allTags.size - index if (index < 0) 0 else allTags.size - index
} }
return (weight / maxWeight).pow(2.0).toFloat() return (weight / maxWeight).pow(2.0).toFloat()
} }
private suspend fun MangaRepository.getListSafe(tag: MangaTag) = runCatchingCancellable { private fun Iterable<String>.inexactIndexOf(element: String, threshold: Float): Int {
getList(offset = 0, sortOrder = SortOrder.UPDATED, tags = setOf(tag)) forEachIndexed { i, t ->
}.onFailure { error -> if (t.almostEquals(element, threshold)) {
error.printStackTraceDebug() return i
}.getOrDefault(emptyList()) }
}
private fun MangaRepository.Factory.tryCreate(source: MangaSource) = runCatching { return -1
create(source) }
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
companion object { companion object {
private const val TAG = "suggestions" private const val TAG = "suggestions"
private const val TAG_ONESHOT = "suggestions_oneshot" private const val TAG_ONESHOT = "suggestions_oneshot"
private const val LIMIT = 140
private const val TAGS_LIMIT = 20
private const val MAX_PARALLELISM = 4
private const val DATA_COUNT = "count" private const val DATA_COUNT = "count"
private const val WORKER_CHANNEL_ID = "suggestion_worker" private const val WORKER_CHANNEL_ID = "suggestion_worker"
private const val MANGA_CHANNEL_ID = "suggestions"
private const val WORKER_NOTIFICATION_ID = 36 private const val WORKER_NOTIFICATION_ID = 36
private const val MAX_RESULTS = 80
private const val MAX_RAW_RESULTS = 200
private const val TAG_EQ_THRESHOLD = 0.4f
private val preferredSortOrders = listOf(
SortOrder.UPDATED,
SortOrder.NEWEST,
SortOrder.POPULARITY,
SortOrder.RATING,
)
fun setup(context: Context) { fun setup(context: Context) {
val constraints = Constraints.Builder() val constraints = Constraints.Builder()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArrayMap
import androidx.collection.ArraySet import androidx.collection.ArraySet
import java.util.Collections import java.util.Collections
@@ -45,3 +46,17 @@ inline fun <T> Collection<T>.filterToSet(predicate: (T) -> Boolean): Set<T> {
fun <T> Sequence<T>.toListSorted(comparator: Comparator<T>): List<T> { fun <T> Sequence<T>.toListSorted(comparator: Comparator<T>): List<T> {
return toMutableList().apply { sortWith(comparator) } return toMutableList().apply { sortWith(comparator) }
} }
fun <T> List<T>.takeMostFrequent(limit: Int): List<T> {
val map = ArrayMap<T, Int>(size)
for (item in this) {
map[item] = map.getOrDefault(item, 0) + 1
}
val entries = map.entries.sortedByDescending { it.value }
val count = minOf(limit, entries.size)
return buildList(count) {
repeat(count) { i ->
add(entries[i].key)
}
}
}

View File

@@ -4,6 +4,7 @@ import android.os.SystemClock
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -43,3 +44,11 @@ fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
fun <T> StateFlow<T?>.requireValue(): T = checkNotNull(value) { fun <T> StateFlow<T?>.requireValue(): T = checkNotNull(value) {
"StateFlow value is null" "StateFlow value is null"
} }
fun <T> Flow<Collection<T>>.flatten(): Flow<T> = flow {
collect { value ->
for (item in value) {
emit(item)
}
}
}

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import androidx.annotation.FloatRange
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import java.util.UUID import java.util.UUID
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String { inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
@@ -21,3 +23,14 @@ fun String.toUUIDOrNull(): UUID? = try {
e.printStackTraceDebug() e.printStackTraceDebug()
null null
} }
/**
* @param threshold 0 = exact match
*/
fun String.almostEquals(other: String, @FloatRange(from = 0.0) threshold: Float): Boolean {
if (threshold == 0f) {
return equals(other, ignoreCase = true)
}
val diff = lowercase().levenshteinDistance(other.lowercase()) / ((length + other.length) / 2f)
return diff < threshold
}

View File

@@ -0,0 +1,17 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#FFFFFF"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:scaleX="1.0036364"
android:scaleY="1.0036364"
android:translateX="-0.043636363"
android:translateY="-0.043636363">
<path
android:fillColor="#FF000000"
android:pathData="M8.6,1.535L6.711,4.715L3.1,5.525L3.439,9.207L1,11.994L3.439,14.773L3.1,18.465L6.711,19.283L8.6,22.465L12,20.994L15.4,22.457L17.289,19.273L20.9,18.457L20.561,14.773L23,11.994L20.561,9.217L20.9,5.535L17.289,4.715L15.4,1.535L12,2.996L8.6,1.535zM10.068,8.521L13.932,8.521C14.357,8.521 14.705,8.866 14.705,9.295L14.705,15.479L12,14.318L9.295,15.479L9.295,9.295A0.773,0.773 0,0 1,10.068 8.521z" />
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

View File

@@ -448,6 +448,9 @@
<string name="cancel_all">Cancel all</string> <string name="cancel_all">Cancel all</string>
<string name="downloads_wifi_only">Download only via Wi-Fi</string> <string name="downloads_wifi_only">Download only via Wi-Fi</string>
<string name="downloads_wifi_only_summary">Stop downloading when switching to a mobile network</string> <string name="downloads_wifi_only_summary">Stop downloading when switching to a mobile network</string>
<string name="suggestion_manga">Suggestion: %s</string>
<string name="suggestions_notifications_summary">Sometimes show notifications with suggested manga</string>
<string name="more">More</string>
<string name="enable">Enable</string> <string name="enable">Enable</string>
<string name="no_thanks">No thanks</string> <string name="no_thanks">No thanks</string>
<string name="cancel_all_downloads_confirm">All active downloads will be cancelled, partially downloaded data will be lost</string> <string name="cancel_all_downloads_confirm">All active downloads will be cancelled, partially downloaded data will be lost</string>
@@ -457,4 +460,5 @@
<string name="downloads_paused">Downloads have been paused</string> <string name="downloads_paused">Downloads have been paused</string>
<string name="downloads_removed">Downloads have been removed</string> <string name="downloads_removed">Downloads have been removed</string>
<string name="downloads_cancelled">Downloads have been cancelled</string> <string name="downloads_cancelled">Downloads have been cancelled</string>
<string name="suggestions_enable_prompt">Do you want to receive personalized manga suggestions?</string>
</resources> </resources>

View File

@@ -9,13 +9,22 @@
android:layout="@layout/preference_toggle_header" android:layout="@layout/preference_toggle_header"
android:title="@string/suggestions_enable" /> android:title="@string/suggestions_enable" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:dependency="suggestions"
android:key="suggestions_notifications"
android:summary="@string/suggestions_notifications_summary"
android:title="@string/notifications_enable" />
<org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference <org.koitharu.kotatsu.settings.utils.MultiAutoCompleteTextViewPreference
android:dependency="suggestions" android:dependency="suggestions"
android:key="suggestions_exclude_tags" android:key="suggestions_exclude_tags"
android:summary="@string/suggestions_excluded_genres_summary" android:summary="@string/suggestions_excluded_genres_summary"
android:title="@string/suggestions_excluded_genres" /> android:title="@string/suggestions_excluded_genres"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false"
android:dependency="suggestions" android:dependency="suggestions"
android:key="suggestions_exclude_nsfw" android:key="suggestions_exclude_nsfw"
android:title="@string/exclude_nsfw_from_suggestions" /> android:title="@string/exclude_nsfw_from_suggestions" />
@@ -28,4 +37,4 @@
android:summary="@string/suggestions_info" android:summary="@string/suggestions_info"
app:allowDividerAbove="true" /> app:allowDividerAbove="true" />
</PreferenceScreen> </PreferenceScreen>