From 52655cad2c08dedfbddcfb65f99968e8dd81b429 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 9 May 2023 18:35:33 +0300 Subject: [PATCH] New suggestions algorithm --- .../org/koitharu/kotatsu/core/model/Manga.kt | 2 + .../kotatsu/core/prefs/AppSettings.kt | 15 ++ .../kotatsu/favourites/data/EntityMapping.kt | 10 +- .../kotatsu/favourites/data/FavouritesDao.kt | 4 + .../favourites/domain/FavouritesRepository.kt | 17 +- .../domain/SuggestionRepository.kt | 2 +- .../suggestions/domain/TagsBlacklist.kt | 23 ++ .../suggestions/ui/SuggestionsWorker.kt | 236 +++++++++++++----- .../kotatsu/utils/ext/CollectionExt.kt | 15 ++ .../org/koitharu/kotatsu/utils/ext/FlowExt.kt | 9 + .../koitharu/kotatsu/utils/ext/StringExt.kt | 13 + .../ic_stat_suggestion.xml | 17 ++ .../res/drawable-hdpi/ic_stat_suggestion.png | Bin 0 -> 542 bytes .../res/drawable-mdpi/ic_stat_suggestion.png | Bin 0 -> 362 bytes .../res/drawable-xhdpi/ic_stat_suggestion.png | Bin 0 -> 719 bytes .../drawable-xxhdpi/ic_stat_suggestion.png | Bin 0 -> 967 bytes app/src/main/res/values/strings.xml | 3 + app/src/main/res/xml/pref_suggestions.xml | 13 +- 18 files changed, 311 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt create mode 100644 app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_suggestion.png diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt index d1c3f849e..f9720bb13 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/model/Manga.kt @@ -9,6 +9,8 @@ import org.koitharu.kotatsu.utils.ext.iterator fun Collection.ids() = mapToSet { it.id } +fun Collection.distinctById() = distinctBy { it.id } + fun Collection.countChaptersByBranch(): Int { if (size <= 1) { return size diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 01b79b675..33956dd1a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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 @@ -250,6 +251,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isSuggestionsExcludeNsfw: Boolean get() = prefs.getBoolean(KEY_SUGGESTIONS_EXCLUDE_NSFW, false) + val isSuggestionsNotificationAvailable: Boolean + get() = prefs.getBoolean(KEY_SUGGESTIONS_NOTIFICATIONS, true) + + val suggestionsTagsBlacklist: Set + 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,6 +292,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { return policy.isNetworkAllowed(connectivityManager) } + @Deprecated("") fun getSuggestionsTagsBlacklistRegex(): Regex? { val string = prefs.getString(KEY_SUGGESTIONS_EXCLUDE_TAGS, null)?.trimEnd(' ', ',') if (string.isNullOrEmpty()) { @@ -381,6 +395,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" diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt index 36308d4c6..af468c3e8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/EntityMapping.kt @@ -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, -) \ No newline at end of file +) + +fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags()) + +fun Collection.toMangaList() = map { it.toManga() } diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 0e0ee05de..590b66436 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -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 + @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 + fun observeAll(order: SortOrder): Flow> { val orderBy = getOrderBy(order) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 3875eb2e5..72fb8d48c 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -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 { val entities = db.favouritesDao.findAll() - return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + return entities.toMangaList() + } + + suspend fun getLastManga(limit: Int): List { + val entities = db.favouritesDao.findLast(limit) + return entities.toMangaList() } fun observeAll(order: SortOrder): Flow> { return db.favouritesDao.observeAll(order) - .mapItems { it.manga.toManga(it.tags.toMangaTags()) } + .mapItems { it.toManga() } } suspend fun getManga(categoryId: Long): List { 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> { return db.favouritesDao.observeAll(categoryId, order) - .mapItems { it.manga.toManga(it.tags.toMangaTags()) } + .mapItems { it.toManga() } } fun observeAll(categoryId: Long): Flow> { diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index f0afaf429..50cd7c7f9 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -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, diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt new file mode 100644 index 000000000..baa88d725 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/domain/TagsBlacklist.kt @@ -0,0 +1,23 @@ +package org.koitharu.kotatsu.suggestions.domain + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.utils.ext.almostEquals + +class TagsBlacklist( + private val tags: Set, + private val threshold: Float, +) { + + fun isNotEmpty() = tags.isNotEmpty() + + operator fun contains(manga: Manga): Boolean { + for (mangaTag in manga.tags) { + for (tagTitle in tags) { + if (mangaTag.title.almostEquals(tagTitle, threshold)) { + return true + } + } + } + return false + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 64d77f505..3f136eb26 100644 --- a/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/java/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -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) @@ -79,7 +100,6 @@ class SuggestionsWorker @AssistedInject constructor( .setPriority(NotificationCompat.PRIORITY_MIN) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setDefaults(0) - .setColor(ContextCompat.getColor(applicationContext, R.color.blue_primary_dark)) .setSilent(true) .setProgress(0, 0, true) .setSmallIcon(android.R.drawable.stat_notify_sync) @@ -94,83 +114,185 @@ 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, 0.3f) + 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() + } } + 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) + showNotification(details) + }.onFailure { + it.printStackTraceDebug() + } + } + return suggestions.size + } + + private suspend fun getList( + source: MangaSource, + tags: List, + blacklist: TagsBlacklist, + ): List = 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, threshold = 0.3f) } + } + val list = repository.getList(0, setOfNotNull(tag), order).asArrayList() if (appSettings.isSuggestionsExcludeNsfw) { - rawResults.removeAll { it.isNsfw } + list.removeAll { it.isNsfw } } - if (blacklistTagRegex != null) { - rawResults.removeAll { - it.tags.any { x -> blacklistTagRegex.containsMatchIn(x.title) } - } + if (blacklist.isNotEmpty()) { + list.removeAll { manga -> manga in blacklist } } - if (rawResults.isEmpty()) { - return 0 + 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 suggestions = rawResults.distinctBy { manga -> - manga.id - }.map { manga -> - MangaSuggestion( - manga = manga, - relevance = computeRelevance(manga.tags, allTags), + 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(), ) - }.sortedBy { it.relevance }.take(LIMIT) - suggestionRepository.replace(suggestions) - return suggestions.size + 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, allTags: List): Float { + private fun computeRelevance(mangaTags: Set, allTags: List): 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.indexOf(tag.title) 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() - 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 val preferredSortOrders = listOf( + SortOrder.UPDATED, + SortOrder.NEWEST, + SortOrder.POPULARITY, + SortOrder.RATING, + ) fun setup(context: Context) { val constraints = Constraints.Builder() diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt index 6f6513707..879704445 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CollectionExt.kt @@ -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 Collection.filterToSet(predicate: (T) -> Boolean): Set { fun Sequence.toListSorted(comparator: Comparator): List { return toMutableList().apply { sortWith(comparator) } } + +fun List.takeMostFrequent(limit: Int): List { + val map = ArrayMap(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) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt index 4c08eec67..0ed270991 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/FlowExt.kt @@ -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 Flow.throttle(timeoutMillis: (T) -> Long): Flow { fun StateFlow.requireValue(): T = checkNotNull(value) { "StateFlow value is null" } + +fun Flow>.flatten(): Flow = flow { + collect { value -> + for (item in value) { + emit(item) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt index a5054b5e0..bfb763325 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/StringExt.kt @@ -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) + } + val diff = levenshteinDistance(other) / ((length + other.length) / 2f) + return diff < threshold +} diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml new file mode 100644 index 000000000..3da7d00e0 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_suggestion.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-hdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..76ab52d8a5a2ee8dde37a4b59e7b420fa29bd2f3 GIT binary patch literal 542 zcmV+(0^$9MP)s-F)7(03mc(WSs~(2u#oaA;!oh8u)|)lkW%tTD5WfHlw?7PXRt+3HRg*_^w_EVP6pGeqe1be$|G*D(AgPm748Q5c=1{UVnVZqfwk_Glw5w$nQ zxVSt>Qe<-%Z#2O5L6RQTT;gd1d>qux0qwjIf$M|J4@eAVdDDW+gBIDVfrV{$7|;&F z*S%05y%y)7ebr-yT?VQL;p2+Xi{>Q1pbqX!3_>pT{6t)H zUTP4UrMQ+s)f0 z!Y`TR<$W{rlKtCiof|W|g?i3@54zUZQ56%-MSIK6W3_qxQPMcqIi@ zTxvcF1gF~MFx6Z%w*Sj~fi8l$Cdd6U2?}n^|LJ!*)BTh41H>53_5;sM^8f$<07*qo IM6N<$f`hK4aR2}S literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png b/app/src/main/res/drawable-xhdpi/ic_stat_suggestion.png new file mode 100644 index 0000000000000000000000000000000000000000..e85724bb9d26eabd7cc24999533ba3881a65381f GIT binary patch literal 719 zcmV;=0xW)iKP`kvss3^cV0yXnhIwqai`@4*B`bI;;x0$8_+w_fs20vty z4tPNS+R@M*rs#kj`qzR6jY(IfRRrh;&eM61ac(7K)GrN7x20drlOo|}-m)x1Kl6D} z(^}3hEo;o@MNMz$l91QT=S5A==#r49%;!Z-GjvJFBj)p>rfIq)WQzH`sA+;O3AxC8 zUet7!E*^eLL-Zl48I`us>*EZ4yr}5{T|B&`M;WSJV6%tw^k|714qEuPG)JE@RQsIG z9$c0|`V^wVL(*>;v_qW>I9w+0zo;|cWM`Yyy?|qrUO80oksSfVSirGJzg=|U6)OXY zwSZ%up3ZchRe{7@-~~N*(S`S{3?SA5@93991>f1xCNUP+pw~aBXwa%1VCr7L-Rbw! zs}RdhW3C~D*#e&tX}I9?&;@>2=&4K%r|9D0?*f+H$yY2p_&Y622b!*jE^xBZ+cwtv zp;Zxc(xkP1i1kIfZs7}@V?Hlxx=fdZOfa7pHQk^~LZ+F|i~V0m${FVKqNZ88B&5=z z{w-Y+Qt7eEI$aX7%6widyO|A1*Q9TGzbLqwZnn!1b`vW@Mu#nXTG7e5p^Qc8k~G4( zwP4+OxzS*Y{7sOl(pZixiRItq>tL3h5M06Y=&9BoHhV)Ax zig*K23!8|DLDA!L*je5Eo86f?=gem3aDMQD?94pxc>^Z9vokX=kR(YGIb+OL)n>l# z%4XS2zFu1zW=Zv1^;NZ#+l$h%yRraNkbszS%J}&wXn}0}QKv zaw@o|8eieq@oZZ+HhY-I)`k{;hZA-2UbQDH`|qM|vu`bE;afOKg@XM7=ou~gEvKl^ zYxeJJTJRB0&;tj#j=rRY4w?L4%vG>Ghq#`;qeWig%6sTM*R$%hs6o{M)fLst_Vuoc z7plvuX|A<$YPv?XQFT=HtYuOC?yhRLYK$`lq2ODpkE&m7ZR=@S^-8tCIh`r&5t_u{ z3FmaCuBTg%rA?1o{lrW6a`F~#+P)#ut! z7sv$rkUV9&oy`T<#;e?XQ=|HZn~PHRp2C7sWtKl9z0^hPnCNgp-xDc~ei zeK5Z)=6Uk_u-asLaJoaAW>p`un&tMUwlF_oPIojh-4!xNRA;$Q3%hLu#{|)u83Y@+ z%ChVwGxjo-BER44KF?Kyq1O=<8+drf7fN`;7hG{ZZ){L^oX?B$*0VtbMmeW5h3#N^ z1cH9`yr1b22zv3tF{Vc#=;bfn-mBDA&on`aF-WL5NRqgBt_!Yxzm!2(y! z3g>3*YpQG5m+Jjs0n4oXXo_#DQ{1-`yKi<3)%(#DP3GUjO}?nMaeGlZc6*i)&%;eD p(|A12Ccf^<#P)ccBuSDE{s4!HuyPNODyjeg002ovPDHLkV1j(P;eY@D literal 0 HcmV?d00001 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0148884e4..4a430f6d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -448,4 +448,7 @@ Cancel all Download only via Wi-Fi Stop downloading when switching to a mobile network + Suggestion: %s + Sometimes show notifications with suggested manga + More diff --git a/app/src/main/res/xml/pref_suggestions.xml b/app/src/main/res/xml/pref_suggestions.xml index b9712a556..c754b220f 100644 --- a/app/src/main/res/xml/pref_suggestions.xml +++ b/app/src/main/res/xml/pref_suggestions.xml @@ -9,13 +9,22 @@ android:layout="@layout/preference_toggle_header" android:title="@string/suggestions_enable" /> + + + android:title="@string/suggestions_excluded_genres" + app:allowDividerAbove="true" /> @@ -28,4 +37,4 @@ android:summary="@string/suggestions_info" app:allowDividerAbove="true" /> - \ No newline at end of file +