Merge branch 'feature/suggestions_v2' into devel
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
17
app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml
Normal file
17
app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml
Normal 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>
|
||||||
BIN
app/src/main/res/drawable-hdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 542 B |
BIN
app/src/main/res/drawable-mdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 362 B |
BIN
app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 719 B |
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 967 B |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user