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>.distinctById() = distinctBy { it.id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
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.parsers.model.MangaSource
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.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.filterToSet
@@ -244,12 +245,25 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val isSuggestionsEnabled: Boolean
var isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
val isSuggestionsExcludeNsfw: Boolean
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
get() = prefs.getBoolean(KEY_READER_BAR, true)
@@ -279,18 +293,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
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> {
val list = remoteSources.toMutableList()
val order = sourcesOrder
@@ -381,6 +383,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SUGGESTIONS = "suggestions"
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SUGGESTIONS_NOTIFICATIONS = "suggestions_notifications"
const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal"

View File

@@ -1,11 +1,16 @@
package org.koitharu.kotatsu.explore.domain
import javax.inject.Inject
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
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(
private val settings: AppSettings,
@@ -14,29 +19,47 @@ class ExploreRepository @Inject constructor(
) {
suspend fun findRandomManga(tagsLimit: Int): Manga {
val blacklistTagRegex = settings.getSuggestionsTagsBlacklistRegex()
val allTags = historyRepository.getPopularTags(tagsLimit).filterNot {
blacklistTagRegex?.containsMatchIn(it.title) ?: false
val blacklistTagRegex = TagsBlacklist(settings.suggestionsTagsBlacklist, 0.4f)
val tags = historyRepository.getPopularTags(tagsLimit).mapNotNull {
if (it in blacklistTagRegex) null else it.title
}
val tag = allTags.randomOrNull()
val source = checkNotNull(tag?.source ?: settings.getMangaSources(includeHidden = false).randomOrNull()) {
"No sources found"
}
val repo = mangaRepositoryFactory.create(source)
val list = repo.getList(
offset = 0,
sortOrder = if (SortOrder.UPDATED in repo.sortOrders) SortOrder.UPDATED else null,
tags = setOfNotNull(tag),
).shuffled()
for (item in list) {
if (settings.isSuggestionsExcludeNsfw && item.isNsfw) {
val sources = settings.getMangaSources(includeHidden = false)
check(sources.isNotEmpty()) { "No sources available" }
for (i in 0..4) {
val list = getList(sources.random(), tags, blacklistTagRegex)
val manga = list.randomOrNull() ?: continue
val details = runCatchingCancellable {
mangaRepositoryFactory.create(manga.source).getDetails(manga)
}.getOrNull() ?: continue
if ((settings.isSuggestionsExcludeNsfw && details.isNsfw) || details in blacklistTagRegex) {
continue
}
if (blacklistTagRegex != null && item.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) }) {
continue
}
return item
return details
}
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
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
@@ -16,6 +17,7 @@ import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
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.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleActionObserver
@@ -76,6 +78,9 @@ class ExploreFragment :
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.isGrid.observe(viewLifecycleOwner, ::onGridModeChanged)
viewModel.onShowSuggestionsTip.observe(viewLifecycleOwner) {
showSuggestionsTip()
}
}
override fun onDestroyView() {
@@ -143,6 +148,19 @@ class ExploreFragment :
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 val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener {

View File

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

View File

@@ -1,9 +1,11 @@
package org.koitharu.kotatsu.favourites.data
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.parsers.model.SortOrder
import java.util.*
import java.util.Date
fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) = FavouriteCategory(
id = id,
@@ -13,4 +15,8 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
createdAt = Date(createdAt),
isTrackingEnabled = track,
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")
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>> {
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.toEntities
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.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
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.SortOrder
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
@@ -32,22 +32,27 @@ class FavouritesRepository @Inject constructor(
suspend fun getAllManga(): List<Manga> {
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>> {
return db.favouritesDao.observeAll(order)
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
.mapItems { it.toManga() }
}
suspend fun getManga(categoryId: Long): List<Manga> {
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>> {
return db.favouritesDao.observeAll(categoryId, order)
.mapItems { it.manga.toManga(it.tags.toMangaTags()) }
.mapItems { it.toManga() }
}
fun observeAll(categoryId: Long): Flow<List<Manga>> {

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.suggestions.domain
import androidx.room.withTransaction
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.MangaDatabase
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.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.utils.ext.mapItems
import javax.inject.Inject
class SuggestionRepository @Inject constructor(
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.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.annotation.FloatRange
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.work.BackoffPolicy
import androidx.work.Constraints
@@ -20,39 +24,56 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.distinctById
import org.koitharu.kotatsu.core.parser.MangaRepository
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.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
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.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.flatten
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
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 java.util.concurrent.TimeUnit
import kotlin.math.pow
import kotlin.random.Random
@HiltWorker
class SuggestionsWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val coil: ImageLoader,
private val suggestionRepository: SuggestionRepository,
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val appSettings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
trySetForeground()
val count = doWorkImpl()
val outputData = workDataOf(DATA_COUNT to count)
return Result.success(outputData)
@@ -93,83 +114,197 @@ class SuggestionsWorker @AssistedInject constructor(
suggestionRepository.clear()
return 0
}
val blacklistTagRegex = appSettings.getSuggestionsTagsBlacklistRegex()
val allTags = historyRepository.getPopularTags(TAGS_LIMIT).filterNot {
blacklistTagRegex?.containsMatchIn(it.title) ?: false
}
if (allTags.isEmpty()) {
val seed = (
historyRepository.getList(0, 20) +
favouritesRepository.getLastManga(20)
).distinctById()
val sources = appSettings.getMangaSources(includeHidden = false)
if (seed.isEmpty() || sources.isEmpty()) {
return 0
}
if (TAG in tags) { // not expedited
trySetForeground()
}
val tagsBySources = allTags.groupBy { x -> x.source }
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
val rawResults = coroutineScope {
tagsBySources.flatMap { (source, tags) ->
val repo = mangaRepositoryFactory.tryCreate(source) ?: return@flatMap emptyList()
tags.map { tag ->
async(dispatcher) {
repo.getListSafe(tag)
}
val tagsBlacklist = TagsBlacklist(appSettings.suggestionsTagsBlacklist, TAG_EQ_THRESHOLD)
val tags = seed.flatMap { it.tags.map { x -> x.title } }.takeMostFrequent(10)
val producer = channelFlow {
for (it in sources.shuffled()) {
launch {
send(getList(it, tags, tagsBlacklist))
}
}.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()) {
return 0
}
val suggestions = rawResults.distinctBy { manga ->
manga.id
}.map { manga ->
MangaSuggestion(
manga = manga,
relevance = computeRelevance(manga.tags, allTags),
)
}.sortedBy { it.relevance }.take(LIMIT)
val suggestions = producer
.flatten()
.take(MAX_RAW_RESULTS)
.map { manga ->
MangaSuggestion(
manga = manga,
relevance = computeRelevance(manga.tags, tags),
)
}.toList()
.sortedBy { it.relevance }
.take(MAX_RESULTS)
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
}
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)
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 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
}
return (weight / maxWeight).pow(2.0).toFloat()
}
private suspend fun MangaRepository.getListSafe(tag: MangaTag) = runCatchingCancellable {
getList(offset = 0, sortOrder = SortOrder.UPDATED, tags = setOf(tag))
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrDefault(emptyList())
private fun MangaRepository.Factory.tryCreate(source: MangaSource) = runCatching {
create(source)
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
private fun Iterable<String>.inexactIndexOf(element: String, threshold: Float): Int {
forEachIndexed { i, t ->
if (t.almostEquals(element, threshold)) {
return i
}
}
return -1
}
companion object {
private const val TAG = "suggestions"
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 WORKER_CHANNEL_ID = "suggestion_worker"
private const val MANGA_CHANNEL_ID = "suggestions"
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) {
val constraints = Constraints.Builder()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.utils.ext
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
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> {
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.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
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) {
"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
import androidx.annotation.FloatRange
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import java.util.UUID
inline fun String?.ifNullOrEmpty(defaultValue: () -> String): String {
@@ -21,3 +23,14 @@ fun String.toUUIDOrNull(): UUID? = try {
e.printStackTraceDebug()
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="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="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="no_thanks">No thanks</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_removed">Downloads have been removed</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>

View File

@@ -9,13 +9,22 @@
android:layout="@layout/preference_toggle_header"
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
android:dependency="suggestions"
android:key="suggestions_exclude_tags"
android:summary="@string/suggestions_excluded_genres_summary"
android:title="@string/suggestions_excluded_genres" />
android:title="@string/suggestions_excluded_genres"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:dependency="suggestions"
android:key="suggestions_exclude_nsfw"
android:title="@string/exclude_nsfw_from_suggestions" />
@@ -28,4 +37,4 @@
android:summary="@string/suggestions_info"
app:allowDividerAbove="true" />
</PreferenceScreen>
</PreferenceScreen>